Skip to content

connectors/metadata: harden POSIX-like object watermarks with nanosecond mtime, inode, ctime_ns, and S3 ETag (#225) - #259

Open
thinkapoorv wants to merge 2 commits into
pathwaycom:mainfrom
thinkapoorv:feature/enhance-watermarks-issue-225
Open

connectors/metadata: harden POSIX-like object watermarks with nanosecond mtime, inode, ctime_ns, and S3 ETag (#225)#259
thinkapoorv wants to merge 2 commits into
pathwaycom:mainfrom
thinkapoorv:feature/enhance-watermarks-issue-225

Conversation

@thinkapoorv

Copy link
Copy Markdown
Contributor

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 size and owner happen to be identical, the second write is invisible to the tracker. This is not an edge case — it occurs regularly in:

  • Log rotation (files are overwritten in rapid succession)
  • CI/CD test pipelines (test fixtures are regenerated between test runs in the same second)
  • Any system using atomic rename-into-place writes at high frequency

2. S3 object replacement
S3 objects can be atomically swapped via a PUT that lands at the same LastModified epoch-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 ScannerTag struct (Rejected)

What it would look like:

pub struct ScannerTag {
    modified_at: u64,
    size: u64,
    owner_id: u32,
    has_mtime: bool,
    mtime_ns: u64,       // +8 bytes
    inode: u64,          // +8 bytes
    ctime_ns: u64,       // +8 bytes
    etag_id: u32,        // +4 bytes
    // ...
}

Pros:

  • Simplest code change — just add fields and update comparisons
  • No structural refactor needed

Cons:

  • ScannerTag is 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 MB
  • Adds S3-specific fields (etag_id) to every POSIX tag even though they are always 0 for local files, and vice-versa
  • The old 24-byte layout assertion must be either deleted or bumped with no tracking of why — an accidental regression magnet for future contributors

Verdict: 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 (including path, seen_at, and all strings) in the snapshot map, eliminating the tag entirely.

Pros:

  • Zero special-case logic — is_changed just calls FileLikeMetadata::is_changed directly
  • No need for an interner

Cons:

  • FileLikeMetadata contains heap-allocated String fields (path, owner, etag) — every entry requires at least 2–3 heap allocations
  • A corpus of 1 M files would carry path strings alone (~30 bytes average) = +30 MB of fragmented heap on top of the struct overhead
  • Pointer-chasing on every polling cycle degrades cache locality on the tight inner loop that compares millions of tags per second
  • The existing OwnerInterner design was specifically introduced to avoid this — regressing it would be a step backwards

Verdict: Rejected. Contradicts the explicit design goal stated in the existing ScannerTag doc-comment: "it must stay small and heap-free".


Approach C — ScannerTag as a discriminated enum with platform-specific variants ✅ Chosen

What it looks like:

pub enum ScannerTag {
    Posix {
        mtime_ns:    u64,   // nanosecond-precision mtime
        size:        u64,
        identity_id: u32,   // interned owner string
        inode:       u64,   // st_ino tie-breaker
        ctime_ns:    u64,   // st_ctime tie-breaker
    },
    S3 {
        last_modified: u64,
        size:          u64,
        identity_id:   u32, // interned ETag string
    },
}

Pros:

  • Zero wasted bytes per variant: POSIX tags carry no ETag field; S3 tags carry no inode/ctime field
  • No heap allocation: all fields are integers; the ETag string is interned to a u32 by IdentityInterner (the generalised successor to OwnerInterner), preserving the heap-free guarantee
  • Semantically clean: the tag variant self-describes the detection strategy — a reviewer can immediately tell from the variant name what fields are compared
  • Size assertion preserved: updated from 24 → 40 bytes with an explicit comment documenting the conscious tradeoff
  • Single interner for all string identities: OwnerInternerIdentityInterner maps both POSIX owner strings and S3 ETag strings with the same O(1) lookup logic

Cons:

  • Slightly larger in-RAM size per entry (40 bytes vs. 24 bytes for the Posix variant) — but only one variant is ever stored per corpus type, so there is no per-entry waste
  • Adds a match arm to is_changed — marginally more complex than a flat struct comparison, but each arm is still a handful of integer comparisons with no branching

Verdict: ✅ Chosen. Best balance of memory efficiency, correctness, and future extensibility.


Implementation Summary

src/connectors/metadata/file_like.rs

Change Detail
FileLikeMetadata Added mtime_ns: Option<u64>, etag: Option<String>, inode: Option<u64>, ctime_ns: Option<u64>
from_fs_meta Extracts mtime_ns from SystemTime::duration_since(UNIX_EPOCH) (nanoseconds); extracts inode and ctime_ns via std::os::unix::fs::MetadataExt behind #[cfg(unix)], falls back to None on Windows/WASM
from_s3_object Captures object.e_tag as the etag field
is_changed Extended to compare all four new fields
ScannerTag Converted from structenum with Posix and S3 variants
OwnerInterner Renamed → IdentityInterner; now interns both owner (POSIX) and etag (S3) strings
Unit tests Updated to use IdentityInterner; all existing test invariants preserved

src/persistence/cached_object_storage.rs

Change Detail
Import OwnerInternerIdentityInterner
CachedObjectStorage owner_interner field → identity_interner
deserialize_metadata Tries new format first; on bincode error falls back to FileLikeMetadataV1 (the six-field legacy struct), up-converts to current format with all new Option fields set to None
Batch loading Same try-new/fallback-V1 pattern applied to EventsBatch deserialization
V1 compat structs FileLikeMetadataV1, EventsBatchV1, MetadataEventV1, EventTypeV1 added at the bottom of the file

python/pathway/tests/test_common.py

Fixed two flake8 E231 violations (missing whitespace after , in tuple[int,int] type expressions in error-message match strings) detected during the linting pass.

CHANGELOG.md

Added a ### Fixed entry under [Unreleased] describing the user-visible fix.


Backward Compatibility

The persistence layer uses bincode for binary serialization, which is positional. Adding fields to FileLikeMetadata or EventType::Update would cause a panic when loading a cache written by an older binary.

Strategy: dual-attempt deserialization with safe fallback

Try deserialize as new format
  └─ OK  → use as-is
  └─ Err → try deserialize as V1 legacy format
               └─ OK  → up-convert (new Option fields = None)
                         → is_changed sees None vs Some → cache miss → re-ingest ✅
               └─ Err → propagate error (truly corrupt data)

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 --check on changed files → ✅ no import-order violations
  • black --check on changed files → ✅ formatting unchanged
  • flake8 on changed files → ✅ zero violations (including the two pre-existing E231 spacing issues in test_common.py that were fixed as part of this pass)

Rust formatting:

  • cargo fmt -- --check across the whole workspace → ✅ no diff produced

Correctness — FileLikeMetadata::is_changed / IdentityInterner::is_changed invariant:
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 use IdentityInterner and 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 original OwnerInterner.

Backward compatibility — V1 deserialization path:
The FileLikeMetadataV1 shadow struct exactly mirrors the six-field layout of the original FileLikeMetadata as encoded by bincode (positional, no field names). The up-conversion sets all four new Option fields to None; when IdentityInterner::is_changed later compares a None stored tag field against a freshly polled Some(mtime_ns), the mismatch triggers a cache miss and the object is safely re-ingested. This was verified by manually tracing the bincode positional encoding rules against the original struct definition.


Types of Changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature or improvement (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

The change is non-breaking. Existing persistence caches are forward-compatible via the V1 fallback. No public Python API is touched.


Related Issues

  1. Fixes Improve watermarks in POSIX-like objects tracker #225Improve watermarks in POSIX-like objects tracker

Checklist

  • My code follows the code style of this project (cargo fmt passes; black, isort, flake8 pass on changed Python file)
  • My change requires a change to the documentation (no public Python API added or removed)
  • I described the modification in the CHANGELOG.md file

@thinkapoorv
thinkapoorv force-pushed the feature/enhance-watermarks-issue-225 branch 3 times, most recently from 07b9c7f to bf054eb Compare July 21, 2026 15:23

@zxqfd555 zxqfd555 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/connectors/metadata/file_like.rs Outdated
Comment thread src/connectors/metadata/file_like.rs Outdated
Comment thread python/pathway/tests/test_common.py Outdated
Comment thread python/pathway/tests/test_common.py Outdated
Comment thread tests/integration/test_cached_object_storage.rs
…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
@thinkapoorv
thinkapoorv force-pushed the feature/enhance-watermarks-issue-225 branch from bf054eb to bf2ae2a Compare July 23, 2026 05:49
@thinkapoorv

Copy link
Copy Markdown
Contributor Author

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.

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:

  1. ScannerTag remains a flat 24-byte struct
    • Removed the enum approach entirely and restored the compact layout.
  2. Dropped the IdentityInterner and S3 ETag changes
    • I agree that interning ETags violates the original interner assumptions due to unbounded growth. The implementation now keeps the existing OwnerInterner unchanged and focuses solely on the local filesystem case.
  3. Scoped the fix to the original issue
    • Replaced second-resolution timestamps with mtime_ns in ScannerTag, addressing the rapid same-second rewrite scenario without introducing additional metadata such as inode or ctime_ns.
  4. Updated the integration test
    • Adjusted the test so it remains consistent with the new watermark representation by updating the high-resolution timestamp alongside the other modified fields.
  5. Removed unrelated changes
    • Reverted the formatting-only edits in test_common.py so the PR remains focused on the watermark improvements.

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.

@thinkapoorv
thinkapoorv force-pushed the feature/enhance-watermarks-issue-225 branch 2 times, most recently from 62163c2 to 87cbf48 Compare July 23, 2026 09:52
@zxqfd555

Copy link
Copy Markdown
Collaborator

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.

@zxqfd555 zxqfd555 self-assigned this Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve watermarks in POSIX-like objects tracker

2 participants