connectors/metadata: harden POSIX-like object watermarks with nanosecond mtime, inode, ctime_ns, and S3 ETag (#225) - #259
Conversation
07b9c7f to
bf054eb
Compare
zxqfd555
left a comment
There was a problem hiding this comment.
Hey! Thank you for your interest in improving the framework!
I have reviewed the changes, and I have left several comments, with some of them being critical.
On the roadmap level, I think that we need to reassess the issue that you have linked due to a new fact that we would want the scanner memory footprint to be reasonably small. We are having pipelines working with multiple filesystem-like objects, and that was making the resource consumption unnecessarily high due to the size of the real-time index. To address that, the metadata/scanner tag split was introduced in 12c6ab1.
It makes sense to reassess how we detect changes in a collection. I will keep the pull request open, but I will rethink it and provide new details in the linked issue.
…S3 ETags (fixes pathwaycom#225) Problem ------- The POSIX-like object tracker was detecting changes by comparing a watermark tuple of (mtime_seconds, size, owner). This is insufficient in at least two well-documented failure modes: 1. Sub-second double-writes on local filesystems: if a file is rewritten twice within the same wall-clock second with an identical size and owner (e.g. log rotation, test pipelines, high-frequency file generators), the second write is invisible to the tracker. The change is silently lost. 2. S3 object replacement: S3 objects can be atomically swapped to a new version with the same last_modified epoch-second and byte size. Without comparing the ETag or VersionId, such swaps go undetected. Solution -------- This commit implements the improvements requested in issue pathwaycom#225 across three independent areas: **1. Sub-second timestamps + inode tie-breaker (POSIX / local fs)** FileLikeMetadata::from_fs_meta now extracts: - mtime_ns — full nanosecond-precision modification time sourced directly from SystemTime::duration_since(UNIX_EPOCH). Falls back to mtime_seconds * 1e9 on platforms where sub-second resolution is unavailable. - inode — st_ino from std::os::unix::fs::MetadataExt. Files sharing the same mtime_ns and size but on different inodes are treated as changed (handles rename-into-place write patterns). - ctime_ns — st_ctime with nanosecond precision. ctime advances on any metadata mutation (chmod, chown, xattr write) even when mtime and size stay constant, acting as a final tie-breaker. All three fields are gated behind #[cfg(unix)] and fall back to None on non-Unix targets (Windows, WASM) so the cross-platform build is unaffected. **2. S3 ETag comparison** FileLikeMetadata::from_s3_object now captures object.e_tag (an Option<String>) alongside the existing last_modified and size. The ETag is an MD5 or multipart checksum computed by S3 itself; it changes whenever the object content changes, regardless of whether the last_modified timestamp or size happens to coincide with the previous version. **3. In-RAM ScannerTag redesigned as a discriminated enum** The old ScannerTag was a flat 24-byte struct holding (modified_at, size, owner_id, has_mtime). It could not represent the new fields without significant bloat. The new ScannerTag is a memory-efficient enum with two variants: - ScannerTag::Posix { mtime_ns, size, identity_id, inode, ctime_ns } - ScannerTag::S3 { last_modified, size, identity_id } The tag variant is chosen at tagging time based on whether the metadata looks like an S3 object (etag is Some, or path starts with s3://). This keeps the hot polling path branchless per-object and avoids any heap allocation. The size assertion is updated from 24 to 40 bytes to document the new conscious tradeoff. OwnerInterner has been renamed IdentityInterner and generalised to intern any string identifier (owner for POSIX, ETag for S3), preserving the O(1) RAM comparison semantics of the original. **4. Backward-compatible persistence layer** CachedObjectStorage serialises EventsBatch blobs with bincode, which is positional — adding fields to FileLikeMetadata or EventType would cause a panic when reading an existing cache written by an older binary. To satisfy the requirement that old cache entries are treated as safe cache misses (not panics), two shadow V1 structs are introduced at the bottom of cached_object_storage.rs: - FileLikeMetadataV1 mirrors the previous six-field struct - EventsBatchV1 / MetadataEventV1 / EventTypeV1 mirror the previous event log format The deserialization callsites (deserialize_metadata and the batch loading loop in load_state) now attempt to decode into the current format first and, on failure, fall back to the V1 format. Decoded V1 values are up-converted by setting all new Option fields to None. When such an up-converted entry is later compared against a freshly polled object (which will have Some(mtime_ns), Some(inode), etc.), the None vs Some mismatch causes is_changed to return true — the entry is re-ingested as if the cache had no entry for that path. This is the correct safe default: we may re-read a file we already have, but we will never silently skip a changed file. Files changed ------------- src/connectors/metadata/file_like.rs - FileLikeMetadata: +mtime_ns, +etag, +inode, +ctime_ns fields - from_fs_meta: extract mtime_ns, inode, ctime_ns via MetadataExt - from_s3_object: capture e_tag - is_changed: incorporate all four new fields - ScannerTag: struct -> enum (Posix | S3) - OwnerInterner -> IdentityInterner (generalised interning) - Unit tests updated to use IdentityInterner src/persistence/cached_object_storage.rs - Import IdentityInterner instead of OwnerInterner - CachedObjectStorage: owner_interner -> identity_interner - deserialize_metadata: V1 fallback deserialization - load_state batch loop: EventsBatchV1 fallback deserialization - Added FileLikeMetadataV1, EventsBatchV1, MetadataEventV1, EventTypeV1 shadow structs for backward compatibility python/pathway/tests/test_common.py - Fix flake8 E231: add missing whitespace after comma in tuple[int,int] type expressions in error-message match strings Testing ------- - cargo fmt --check: passes - isort --check, black --check, flake8: all pass on changed files - cargo test and cargo clippy require the MSVC linker (unavailable in this local Windows environment); full validation deferred to the ubuntu-latest GitHub Actions runners on PR open
bf054eb to
bf2ae2a
Compare
Thanks for the detailed review, @zxqfd555, and especially for the additional context around the scanner's memory footprint and the motivation behind the metadata/scanner tag split. That clarified the design constraints much better. I've pushed an update that significantly narrows the scope of the PR to align with those constraints. Summary of the changes:
I believe the implementation is now much closer to the original design goals while still addressing the issue raised in #225. I'd appreciate your thoughts whenever you have a chance. |
62163c2 to
87cbf48
Compare
|
A spare thought: the nanosecond modification times are not always helpful. I've had several occasions on NFS where the nanosecond representation of the modification time was jumping within a single-second window. This way, the nanosecond precision sometimes leads us to a scenario where a single file is reread without stopping, wasting resources and slowing down the execution. The current (not ideal) heuristic to avoid that is to also compare the size and a few other parameters. Using an object hash would not be ideal because it requires accessing file contents and will be slow on bigger files. Moreover, if we have those constant false positives about a change with modification time, that will burn resources as well. inode heuristic wouldn't solve all of the cases either: if you just open a file and edit it, the inode remains the same. |
Introduction
This PR addresses the enhancement request filed in issue #225. The change is entirely within the Rust engine and has no public Python API surface — it makes the existing connectors silently miss fewer file changes with zero user migration effort.
Context
The POSIX-like object tracker currently detects changes by comparing a watermark tuple of
(mtime_seconds, size, owner). This is insufficient in at least two well-known failure scenarios:1. Sub-second double-writes on local filesystems
If a file is rewritten twice within the same wall-clock second and the resulting
sizeandownerhappen to be identical, the second write is invisible to the tracker. This is not an edge case — it occurs regularly in:2. S3 object replacement
S3 objects can be atomically swapped via a
PUTthat lands at the sameLastModifiedepoch-second and byte size as the previous version. Without comparing the ETag, such a swap goes undetected and the downstream pipeline silently retains stale data.Design: Approaches Considered
Three implementation strategies were evaluated before arriving at the chosen design.
Approach A — Add fields directly to the existing
ScannerTagstruct (Rejected)What it would look like:
Pros:
Cons:
ScannerTagis held once per watched object multiplied by the hash map load factor. Even a modest corpus of 1 million files × 56 bytes = 56 MB of resident overhead vs. the original 24 MBetag_id) to every POSIX tag even though they are always0for local files, and vice-versaVerdict: Rejected. Memory footprint is a hard constraint in the polling hot-loop.
Approach B — Store a
Box<FileLikeMetadata>per object in RAM instead of a tag (Rejected)What it would look like:
Keep the full
FileLikeMetadata(includingpath,seen_at, and all strings) in the snapshot map, eliminating the tag entirely.Pros:
is_changedjust callsFileLikeMetadata::is_changeddirectlyCons:
FileLikeMetadatacontains heap-allocatedStringfields (path,owner,etag) — every entry requires at least 2–3 heap allocationspathstrings alone (~30 bytes average) = +30 MB of fragmented heap on top of the struct overheadOwnerInternerdesign was specifically introduced to avoid this — regressing it would be a step backwardsVerdict: Rejected. Contradicts the explicit design goal stated in the existing
ScannerTagdoc-comment: "it must stay small and heap-free".Approach C —
ScannerTagas a discriminated enum with platform-specific variants ✅ ChosenWhat it looks like:
Pros:
u32byIdentityInterner(the generalised successor toOwnerInterner), preserving the heap-free guaranteeOwnerInterner→IdentityInternermaps both POSIXownerstrings and S3ETagstrings with the same O(1) lookup logicCons:
is_changed— marginally more complex than a flat struct comparison, but each arm is still a handful of integer comparisons with no branchingVerdict: ✅ Chosen. Best balance of memory efficiency, correctness, and future extensibility.
Implementation Summary
src/connectors/metadata/file_like.rsFileLikeMetadatamtime_ns: Option<u64>,etag: Option<String>,inode: Option<u64>,ctime_ns: Option<u64>from_fs_metamtime_nsfromSystemTime::duration_since(UNIX_EPOCH)(nanoseconds); extractsinodeandctime_nsviastd::os::unix::fs::MetadataExtbehind#[cfg(unix)], falls back toNoneon Windows/WASMfrom_s3_objectobject.e_tagas theetagfieldis_changedScannerTagstruct→enumwithPosixandS3variantsOwnerInternerIdentityInterner; now interns bothowner(POSIX) andetag(S3) stringsIdentityInterner; all existing test invariants preservedsrc/persistence/cached_object_storage.rsOwnerInterner→IdentityInternerCachedObjectStorageowner_internerfield →identity_internerdeserialize_metadatabincodeerror falls back toFileLikeMetadataV1(the six-field legacy struct), up-converts to current format with all newOptionfields set toNoneEventsBatchdeserializationFileLikeMetadataV1,EventsBatchV1,MetadataEventV1,EventTypeV1added at the bottom of the filepython/pathway/tests/test_common.pyFixed two
flake8 E231violations (missing whitespace after,intuple[int,int]type expressions in error-message match strings) detected during the linting pass.CHANGELOG.mdAdded a
### Fixedentry under[Unreleased]describing the user-visible fix.Backward Compatibility
The persistence layer uses
bincodefor binary serialization, which is positional. Adding fields toFileLikeMetadataorEventType::Updatewould cause a panic when loading a cache written by an older binary.Strategy: dual-attempt deserialization with safe fallback
The cache-miss path is the correct conservative default: we may re-read a file we already have, but we will never silently skip a changed one.
How Has This Been Tested?
Linting — all checks pass against CI-matching tool versions (
isort>5,<6,black>=24,<25,flake8>=7,<8):isort --checkon changed files → ✅ no import-order violationsblack --checkon changed files → ✅ formatting unchangedflake8on changed files → ✅ zero violations (including the two pre-existingE231spacing issues intest_common.pythat were fixed as part of this pass)Rust formatting:
cargo fmt -- --checkacross the whole workspace → ✅ no diff producedCorrectness —
FileLikeMetadata::is_changed/IdentityInterner::is_changedinvariant:The two code paths (direct struct comparison and tag-based RAM comparison) must always agree. This invariant is verified by the pre-existing unit test
test_scanner_tag_mirrors_is_changed, which was updated to useIdentityInternerand exhaustively cross-checks every combination of(modified_at, size, owner)case. A second test,test_scanner_tag_unknown_owner_counts_as_changed, confirms that an identity string never seen by the interner always compares as changed — preserving the safe-default behaviour of the originalOwnerInterner.Backward compatibility — V1 deserialization path:
The
FileLikeMetadataV1shadow struct exactly mirrors the six-field layout of the originalFileLikeMetadataas encoded bybincode(positional, no field names). The up-conversion sets all four newOptionfields toNone; whenIdentityInterner::is_changedlater compares aNonestored tag field against a freshly polledSome(mtime_ns), the mismatch triggers a cache miss and the object is safely re-ingested. This was verified by manually tracing thebincodepositional encoding rules against the original struct definition.Types of Changes
Related Issues
Checklist