| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| import logging |
| from concurrent.futures import ThreadPoolExecutor |
| from typing import Callable, Dict, List, Optional |
| |
| from pypaimon.common.file_io import FileIO |
| |
| logger = logging.getLogger(__name__) |
| from pypaimon.common.json_util import JSON |
| from pypaimon.snapshot.snapshot import Snapshot |
| from pypaimon.snapshot.snapshot_loader import SnapshotLoader |
| |
| EARLIEST_SNAPSHOT_DEFAULT_RETRY_NUM = 300 |
| |
| |
| class SnapshotManager: |
| """Manager for snapshot files using unified FileIO.""" |
| |
| def __init__( |
| self, |
| file_io: FileIO, |
| table_path: str, |
| branch: Optional[str] = None, |
| snapshot_loader: Optional[SnapshotLoader] = None, |
| ): |
| # Lazy import to avoid a cycle: pypaimon.branch.__init__ |
| # eagerly loads FileSystemBranchManager, which imports |
| # SnapshotManager. |
| from pypaimon.branch.branch_manager import BranchManager |
| |
| self.file_io: FileIO = file_io |
| self.table_path: str = table_path.rstrip('/') |
| self.branch: str = BranchManager.normalize_branch(branch) |
| self.snapshot_loader: Optional[SnapshotLoader] = snapshot_loader |
| |
| branch_root = BranchManager.branch_path(self.table_path, self.branch) |
| self.snapshot_dir = f"{branch_root}/snapshot" |
| self.latest_file = f"{self.snapshot_dir}/LATEST" |
| |
| def copy_with_branch(self, branch_name: str) -> 'SnapshotManager': |
| # Mirrors Java SnapshotManager.copyWithBranch: the new manager |
| # carries a SnapshotLoader rebranched to ``branch_name`` so REST |
| # loads target the correct branch instead of falling back to the |
| # main-branch identifier. |
| rebranched_loader = ( |
| self.snapshot_loader.copy_with_branch(branch_name) |
| if self.snapshot_loader is not None |
| else None |
| ) |
| return SnapshotManager( |
| self.file_io, self.table_path, branch_name, rebranched_loader) |
| |
| def get_latest_snapshot(self) -> Optional[Snapshot]: |
| """ |
| Get the latest snapshot with loader priority. |
| |
| 1. Try to load from snapshotLoader if available |
| 2. Fallback to filesystem if loader is not available or throws NotImplementedError` |
| 3. Return None if no snapshot found |
| |
| Returns: |
| The latest snapshot JSON string, or None if not found |
| """ |
| # Try to load from snapshotLoader if available |
| if self.snapshot_loader is not None: |
| try: |
| snapshot = self.snapshot_loader.load() |
| except NotImplementedError: |
| # Loader not supported, fallback to filesystem |
| snapshot = self._get_latest_snapshot_from_filesystem() |
| except IOError as e: |
| # IO error, re-raise with context |
| raise RuntimeError(f"Failed to load snapshot from loader: {e}") |
| else: |
| # No loader, use filesystem directly |
| snapshot = self._get_latest_snapshot_from_filesystem() |
| return snapshot |
| |
| def _get_latest_snapshot_from_filesystem(self) -> Optional[Snapshot]: |
| """ |
| Get the latest snapshot from the filesystem. The LATEST file is only a |
| hint: when it is missing, stale, or unreadable, reconcile with a scan |
| of the snapshot files, like the Java SnapshotManager.findLatest. |
| |
| If the hint indicates the table has snapshots but none can be resolved |
| (e.g. an unreadable LATEST plus a listing that an IO error truncated to |
| empty), fail closed rather than report the table as empty -- callers |
| such as the immutable-option guard must not mistake a populated table |
| for an empty one. |
| |
| Returns: |
| The latest snapshot, or None if the table genuinely has none |
| """ |
| if not self.file_io.exists(self.snapshot_dir): |
| return None |
| |
| hint_id = None |
| hint_indicates_data = False |
| hint_read_error = None |
| if self.file_io.exists(self.latest_file): |
| try: |
| content = self.file_io.read_file_utf8(self.latest_file) |
| if content and content.strip(): |
| hint_indicates_data = True |
| hint_id = int(content.strip()) |
| except IOError as e: |
| # A present-but-unreadable hint most likely guards existing |
| # snapshots; remember the failure so we fail closed below. |
| hint_indicates_data = True |
| hint_read_error = e |
| except ValueError: |
| # Non-empty but corrupt hint; reconcile with a listing. |
| hint_indicates_data = True |
| |
| # Trust the hint only when it points to an existing snapshot and no |
| # newer snapshot exists -- a lagging _commit_latest_hint leaves the |
| # hint stale. Otherwise reconcile with a directory listing (mirrors |
| # Java SnapshotManager.findLatest). |
| latest_snapshot_id = hint_id |
| if (latest_snapshot_id is None |
| or not self.file_io.exists(self.get_snapshot_path(latest_snapshot_id)) |
| or self.file_io.exists(self.get_snapshot_path(latest_snapshot_id + 1))): |
| latest_snapshot_id = self._max_snapshot_id_from_listing() |
| |
| if latest_snapshot_id is None: |
| if hint_indicates_data: |
| raise RuntimeError( |
| f"Snapshot hint under {self.snapshot_dir} indicates existing " |
| f"snapshots but none could be resolved" |
| + (f": {hint_read_error}" if hint_read_error else "")) |
| return None |
| |
| snapshot_file = self.get_snapshot_path(latest_snapshot_id) |
| return JSON.from_json(self.file_io.read_file_utf8(snapshot_file), Snapshot) |
| |
| def _max_snapshot_id_from_listing(self) -> Optional[int]: |
| """Scan the snapshot directory for the largest snapshot-N file id.""" |
| import re |
| |
| snapshot_pattern = re.compile(r'^snapshot-(\d+)$') |
| max_snapshot_id = None |
| for file_info in self.file_io.list_status(self.snapshot_dir): |
| match = snapshot_pattern.match(file_info.path.split('/')[-1]) |
| if match: |
| snapshot_id = int(match.group(1)) |
| if max_snapshot_id is None or snapshot_id > max_snapshot_id: |
| max_snapshot_id = snapshot_id |
| return max_snapshot_id |
| |
| def read_latest_file(self, max_retries: int = 5): |
| """ |
| Read the latest snapshot ID from LATEST file with retry mechanism. |
| If file doesn't exist or is empty after retries, scan snapshot directory for max ID. |
| """ |
| import re |
| import time |
| |
| # Try to read LATEST file with retries |
| for retry_count in range(max_retries): |
| try: |
| if self.file_io.exists(self.latest_file): |
| content = self.file_io.read_file_utf8(self.latest_file) |
| if content and content.strip(): |
| return content.strip() |
| |
| # File doesn't exist or is empty, wait a bit before retry |
| if retry_count < max_retries - 1: |
| time.sleep(0.001) |
| |
| except Exception: |
| # On exception, wait and retry |
| if retry_count < max_retries - 1: |
| time.sleep(0.001) |
| |
| # List all files in snapshot directory |
| file_infos = self.file_io.list_status(self.snapshot_dir) |
| |
| max_snapshot_id = None |
| snapshot_pattern = re.compile(r'^snapshot-(\d+)$') |
| |
| for file_info in file_infos: |
| # Get filename from path |
| filename = file_info.path.split('/')[-1] |
| match = snapshot_pattern.match(filename) |
| if match: |
| snapshot_id = int(match.group(1)) |
| if max_snapshot_id is None or snapshot_id > max_snapshot_id: |
| max_snapshot_id = snapshot_id |
| |
| if not max_snapshot_id: |
| raise RuntimeError(f"No snapshot content found in {self.snapshot_dir}") |
| |
| return str(max_snapshot_id) |
| |
| def get_snapshot_path(self, snapshot_id: int) -> str: |
| """Get the file path for the given snapshot ID.""" |
| return f"{self.snapshot_dir}/snapshot-{snapshot_id}" |
| |
| def try_get_earliest_snapshot( |
| self, stop_snapshot_id: Optional[int] = None |
| ) -> Optional[Snapshot]: |
| earliest_file = f"{self.snapshot_dir}/EARLIEST" |
| if self.file_io.exists(earliest_file): |
| earliest_content = self.file_io.read_file_utf8(earliest_file) |
| earliest_snapshot_id = int(earliest_content.strip()) |
| else: |
| earliest_snapshot_id = 1 |
| |
| if stop_snapshot_id is None: |
| stop_snapshot_id = earliest_snapshot_id + EARLIEST_SNAPSHOT_DEFAULT_RETRY_NUM |
| |
| for snapshot_id in range(earliest_snapshot_id, stop_snapshot_id + 1): |
| snapshot = self.get_snapshot_by_id(snapshot_id) |
| if snapshot is not None: |
| return snapshot |
| raise RuntimeError( |
| f"Cannot find earliest snapshot from #{earliest_snapshot_id} " |
| f"to #{stop_snapshot_id}." |
| ) |
| |
| def earlier_or_equal_time_mills(self, timestamp: int) -> Optional[Snapshot]: |
| """ |
| Find the latest snapshot with time_millis <= the given timestamp. |
| |
| Args: |
| timestamp: The timestamp to compare against |
| |
| Returns: |
| The latest snapshot with time_millis <= timestamp, or None if no such snapshot exists |
| """ |
| latest_snap = self.get_latest_snapshot() |
| |
| if latest_snap is None: |
| return None |
| |
| earliest_snap = self.try_get_earliest_snapshot(latest_snap.id) |
| if earliest_snap is None: |
| return None |
| |
| earliest = earliest_snap.id |
| latest = latest_snap.id |
| final_snapshot = None |
| |
| while earliest <= latest: |
| mid = earliest + (latest - earliest) // 2 |
| snapshot = self.get_snapshot_by_id(mid) |
| |
| # Handle gaps in snapshot sequence (expired snapshots) |
| if snapshot is None: |
| # Search forward to find next existing snapshot |
| found = False |
| for i in range(mid + 1, latest + 1): |
| snapshot = self.get_snapshot_by_id(i) |
| if snapshot is not None: |
| mid = i |
| found = True |
| break |
| if not found: |
| # No snapshots from mid to latest, search lower half |
| latest = mid - 1 |
| continue |
| |
| commit_time = snapshot.time_millis |
| |
| if commit_time > timestamp: |
| latest = mid - 1 |
| elif commit_time < timestamp: |
| earliest = mid + 1 |
| final_snapshot = snapshot |
| else: |
| final_snapshot = snapshot |
| break |
| |
| return final_snapshot |
| |
| def list_snapshots(self) -> List[Snapshot]: |
| """Return every persisted snapshot in ascending ID order. |
| |
| Scans ``snapshot_dir`` for ``snapshot-N`` files and decodes |
| each. IDs whose file is missing (because a previous expire |
| cleaned them) are skipped silently, so the result reflects |
| only snapshots that can still be inspected. |
| """ |
| import re |
| |
| if not self.file_io.exists(self.snapshot_dir): |
| return [] |
| |
| file_infos = self.file_io.list_status(self.snapshot_dir) |
| if file_infos is None: |
| return [] |
| |
| pattern = re.compile(r'^snapshot-(\d+)$') |
| ids = [] |
| for file_info in file_infos: |
| name = file_info.path.split('/')[-1] |
| match = pattern.match(name) |
| if match: |
| ids.append(int(match.group(1))) |
| ids.sort() |
| |
| snapshots: List[Snapshot] = [] |
| for snapshot_id in ids: |
| snapshot = self.get_snapshot_by_id(snapshot_id) |
| if snapshot is not None: |
| snapshots.append(snapshot) |
| return snapshots |
| |
| def later_or_equal_watermark(self, watermark: int) -> Optional[Snapshot]: |
| """ |
| Find the first snapshot with watermark >= the given value. |
| |
| Args: |
| watermark: The watermark value to compare against |
| |
| Returns: |
| The first snapshot with watermark >= the given value, or None if |
| no such snapshot exists |
| """ |
| earliest_snap = self.try_get_earliest_snapshot() |
| latest_snap = self.get_latest_snapshot() |
| |
| if earliest_snap is None or latest_snap is None: |
| return None |
| |
| earliest = earliest_snap.id |
| latest = latest_snap.id |
| result = None |
| |
| while earliest <= latest: |
| mid = earliest + (latest - earliest) // 2 |
| snapshot = self.get_snapshot_by_id(mid) |
| |
| if snapshot is None: |
| found = False |
| for i in range(mid + 1, latest + 1): |
| snapshot = self.get_snapshot_by_id(i) |
| if snapshot is not None: |
| mid = i |
| found = True |
| break |
| if not found: |
| latest = mid - 1 |
| continue |
| |
| snap_watermark = snapshot.watermark |
| if snap_watermark is None: |
| earliest = mid + 1 |
| continue |
| |
| if snap_watermark >= watermark: |
| result = snapshot |
| latest = mid - 1 |
| else: |
| earliest = mid + 1 |
| |
| return result |
| |
| def get_snapshot_by_id(self, snapshot_id: int) -> Optional[Snapshot]: |
| """ |
| Get a snapshot by its ID. |
| |
| Args: |
| snapshot_id: The snapshot ID |
| |
| Returns: |
| The snapshot with the specified ID, or None if not found |
| """ |
| snapshot_file = self.get_snapshot_path(snapshot_id) |
| if not self.file_io.exists(snapshot_file): |
| return None |
| snapshot_content = self.file_io.read_file_utf8(snapshot_file) |
| return JSON.from_json(snapshot_content, Snapshot) |
| |
| def get_snapshots_batch( |
| self, snapshot_ids: List[int], max_workers: int = 4 |
| ) -> Dict[int, Optional[Snapshot]]: |
| """Fetch multiple snapshots in parallel, returning {id: Snapshot|None}.""" |
| if not snapshot_ids: |
| return {} |
| |
| # First, batch check which snapshot files exist |
| paths = [self.get_snapshot_path(sid) for sid in snapshot_ids] |
| existence = self.file_io.exists_batch(paths) |
| |
| # Filter to only existing snapshots |
| existing_ids = [ |
| sid for sid, path in zip(snapshot_ids, paths) |
| if existence.get(path, False) |
| ] |
| |
| if not existing_ids: |
| return {sid: None for sid in snapshot_ids} |
| |
| # Fetch existing snapshots in parallel |
| def fetch_one(sid: int) -> tuple: |
| try: |
| return (sid, self.get_snapshot_by_id(sid)) |
| except Exception: |
| return (sid, None) |
| |
| results = {sid: None for sid in snapshot_ids} |
| |
| with ThreadPoolExecutor(max_workers=min(len(existing_ids), max_workers)) as executor: |
| for sid, snapshot in executor.map(fetch_one, existing_ids): |
| results[sid] = snapshot |
| |
| return results |
| |
| def find_next_scannable( |
| self, |
| start_id: int, |
| should_scan: Callable[[Snapshot], bool], |
| lookahead_size: int = 10, |
| max_workers: int = 4 |
| ) -> tuple: |
| """Find the next snapshot passing should_scan, using batch lookahead.""" |
| # Generate the range of snapshot IDs to check |
| snapshot_ids = list(range(start_id, start_id + lookahead_size)) |
| |
| # Batch fetch all snapshots |
| snapshots = self.get_snapshots_batch(snapshot_ids, max_workers) |
| |
| # Find the first scannable snapshot in order |
| skipped_count = 0 |
| for sid in snapshot_ids: |
| snapshot = snapshots.get(sid) |
| if snapshot is None: |
| # No more snapshots exist at this ID |
| # Return next_id = sid so caller knows where to wait |
| return (None, sid, skipped_count) |
| if should_scan(snapshot): |
| # Found a scannable snapshot |
| return (snapshot, sid + 1, skipped_count) |
| skipped_count += 1 |
| |
| # All fetched snapshots were skipped, but more may exist |
| # Return next_id pointing past the batch |
| return (None, start_id + lookahead_size, skipped_count) |