Skip to content

[Python] Refactor MatchContinuously onto the Watch transform - #39461

Open
Eliaaazzz wants to merge 15 commits into
apache:masterfrom
Eliaaazzz:matchcontinuously-on-watch
Open

[Python] Refactor MatchContinuously onto the Watch transform#39461
Eliaaazzz wants to merge 15 commits into
apache:masterfrom
Eliaaazzz:matchcontinuously-on-watch

Conversation

@Eliaaazzz

@Eliaaazzz Eliaaazzz commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Reviewer guide after history correction

Apologies for the earlier force push. I understand this makes review harder and will not force-push this PR branch again.

For auditability, the reviewed code tree from 0c00e43b937912577939ec9eab3e0cb6d7772095 and the current head 626ee74c97ee9c225edd6463cfc56c5e5760bfc8 are identical:

git diff 0c00e43b93 626ee74c97 is empty.

I then restored normal PR history with regular commits only:

  • f184d2f365: revert 0526f7d9b5
  • 626ee74c97: re-apply the same code change

Suggested current review path:

  • sdks/python/apache_beam/io/fileio.py: _MatchContinuouslyPollFn, especially timestamping and watermark handling.
  • sdks/python/apache_beam/io/fileio.py: timestamp_cursor docs and interaction with match_updated_files.
  • sdks/python/apache_beam/io/fileio_test.py: cursor and watermark coverage.
  • sdks/python/apache_beam/io/watch_test.py: retained-key / allowed-lateness behavior.

Open reviewer decisions:

  • Whether allowed_lateness should also be exposed on MatchContinuously.
  • Whether the documented weakening of match_updated_files under timestamp_cursor is acceptable, or whether already-seen output timestamps should be carried through claim processing.

Relevant existing review threads:

Existing review threads are preserved in the conversation/outdated threads; this guide is only to make the current review path easier to follow.


Routes fileio.MatchContinuously through the Watch transform when deduplication is enabled.

Built on the Watch transform, which merged in #39023.

What changes

The polling loop and the set of already-matched file ids move into the splittable DoFn restriction. The per-key state DoFns _RemoveDuplicates and _RemoveOldDuplicates are removed, since Watch performs the deduplication.

has_deduplication=False keeps the previous PeriodicImpulse behaviour, so that path is unchanged.

Behaviour change worth calling out

Because the matched ids are part of the restriction, a runner with checkpointing enabled restores them after a restart and does not reprocess files. The class docstring previously stated the opposite, that already processed files are reprocessed on restart, which was accurate for the earlier memory-only implementation. The docstring is updated in this PR.

Validation

Fault tolerance on Flink 1.20 with checkpointing enabled: two files present at start, two added while running, then the TaskManager was killed mid stream. The JobManager restored the job from checkpoint 3 and every file was still emitted exactly once, with no reprocessing.

Completed checkpoint 3 for job 15502720... (56814 bytes)
Job beam-watch-matchcontinuously switched from state RUNNING to RESTARTING
Job beam-watch-matchcontinuously switched from state RESTARTING to RUNNING
Restoring job 15502720... from Checkpoint 3

Also exercised on Dataflow Runner v2 reading a real GCS prefix: files present at startup and files added to the bucket mid run were each emitted exactly once.

Unit tests: watch_test.py 28 passed, fileio_test.py MatchContinuously tests 8 passed. Formatted with yapf 0.43.0 and isort 7.0.0.


Thank you for your contribution! Follow this checklist to help us incorporate your contribution quickly and easily:

  • Choose reviewer(s) and mention them in a comment (R: @username).
  • Mention the appropriate issue in your description (for example: addresses #123), if applicable. This will automatically add a link to the pull request in the issue. If you would like the issue to automatically close on merging the pull request, comment fixes #<ISSUE NUMBER> instead.
  • Update CHANGES.md with noteworthy changes.
  • If this contribution is large, please file an Apache Individual Contributor License Agreement.

See the Contributor Guide for more tips on how to make review process smoother.

To check the build health, please visit https://github.com/apache/beam/blob/master/.test-infra/BUILD_STATUS.md

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions

Copy link
Copy Markdown
Contributor

Assigning reviewers:

R: @tvalentyn for label python.

Note: If you would like to opt out of this review, comment assign to next reviewer.

Available commands:

  • stop reviewer notifications - opt out of the automated review tooling
  • remind me after tests pass - tag the comment author after tests pass
  • waiting on author - shift the attention set back to the author (any comment or push by the author will return the attention set to the reviewers)

The PR bot will only process comments in the main thread (not review comments).

@tvalentyn

Copy link
Copy Markdown
Contributor

R: @Abacn

@github-actions

Copy link
Copy Markdown
Contributor

Stopping reviewer notifications for this pull request: review requested by someone other than the bot, ceding control. If you'd like to restart, comment assign set of reviewers

Route MatchContinuously through Watch when deduplication is enabled. The
polling loop and the set of already-matched file ids now live in the
splittable DoFn restriction, replacing the per-key state DoFns.

Because the matched ids are part of the restriction, a runner with
checkpointing enabled restores them after a restart and does not reprocess
files. The docstring is updated accordingly. Verified on Flink 1.20: after
killing the TaskManager mid stream the job restored from a checkpoint and
every file was still emitted exactly once.

has_deduplication=False keeps the previous PeriodicImpulse behaviour.
Comment thread sdks/python/apache_beam/io/fileio.py Outdated
registry.get_coder receives typing and native generic annotations such
as tuple[str, float] unconverted and falls back to pickling. Watch now
converts hints with convert_to_beam_type before the registry lookup, so
MatchContinuously's annotated key functions infer the same StrUtf8Coder
and TupleCoder the explicit settings supplied. Also trims the docstrings
and comments this PR adds.
…ports

Annotates _MatchContinuouslyPollFn with PollResult[FileMetadata], covers
typing.Tuple key inference alongside the native form, reorders the
third-party test imports, and drops the remaining Java references from
test comments.
Comment thread sdks/python/apache_beam/io/fileio.py Outdated
restarted, already processed files will be reprocessed. Consider an alternate
technique, such as Pub/Sub Notifications
(https://cloud.google.com/storage/docs/pubsub-notifications)
file ids for every file the pattern has matched. With ``has_deduplication``

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just a side note, with #39090 in we should be able to eliminate this comment

Matching continuously scales poorly, as it is stateful, and requires storing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Follow up: now #39090 has been merged. We can introduce an option for FileIO.matchContinuously (timestamp_cursor), if set True, then it's backed by watch transform's timestamp_cursor mode

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

One transition detail I would like to confirm with you while wiring this option up. When Watch switches from hash dedup to the cursor, it seeds the cursor from the greatest timestamp in the old completed map. In MatchContinuously those are poll times, while the cursor compares last-modified times, so the two are not the same domain. For now the option's doc says it needs a pipeline started fresh.

The seeding lives in the merged _cursor_of and has its own tests, so I did not change it here. Would you rather keep the seeding as it is, or reject the switch outright when a restriction carries hash state?

watch.py and watch_test.py return to master; the inference fix lands
separately so it can make the release cut. The annotated key functions
meanwhile fall back to the deterministic FastPrimitivesCoder form, which
keeps dedup correct.
Opt-in timestamp_cursor=True backs deduplication with the Watch
transform's cursor mode, so the restriction holds one timestamp rather
than an id per matched file. The poll stamps each match with its
last-modified time, which is what the cursor dedups on, and the
watermark stays at the poll time.

Those event times are floored to the millisecond, the resolution a
runner keeps for element timestamps. A cursor taken from finer mtimes is
persisted truncated and returns below the outputs it came from, matching
every file again on the next poll.

The class docstring no longer calls matching continuously stateful
without qualification, since the cursor bounds the state, and the
startup warning is skipped in that mode.
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.55072% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.83%. Comparing base (b57aaac) to head (626ee74).
⚠️ Report is 42 commits behind head on master.

Files with missing lines Patch % Lines
sdks/python/apache_beam/io/fileio.py 98.86% 1 Missing ⚠️
sdks/python/apache_beam/io/watch.py 98.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #39461      +/-   ##
============================================
+ Coverage     55.96%   58.83%   +2.87%     
- Complexity     2272     7638    +5366     
============================================
  Files          1113     1663     +550     
  Lines        175101   216472   +41371     
  Branches       1458     6536    +5078     
============================================
+ Hits          97988   127361   +29373     
- Misses        74654    84400    +9746     
- Partials       2459     4711    +2252     
Flag Coverage Δ
python 79.89% <98.55%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread sdks/python/apache_beam/io/fileio.py Outdated
Comment thread sdks/python/apache_beam/io/fileio.py Outdated
# Floored to the millisecond a runner keeps for element timestamps, so the
# cursor compares against the same resolution it is persisted at.
micros = Timestamp.of(_mtime_of(metadata, 'timestamp_cursor')).micros
return Timestamp(micros=micros - micros % 1000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why need this special handling here? It should be as correct just pass micros. If there is a watermark issue, check comment below.

@Eliaaazzz Eliaaazzz Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You were right that fileio should just pass micros, and taking the flooring out surfaced the real problem a level down. Watch persists the cursor with TimestampCoder, which encodes milliseconds, so the cursor came back up to a millisecond below the outputs it was taken from and the next poll matched them all again. With the flooring gone and nothing else changed, test_timestamp_cursor_emits_files_modified_past_the_cursor fails with the first file emitted twice. The same rounding hit the checkpoint primary, so a replayed output could repeat its emission in a different window.

Commit 462d2f5 fixes both where they belong. The restriction now encodes microseconds at a fixed width, so the cursor state size test still holds, and the two envelope tags whose payload changed are retired rather than reused, since a millisecond payload is the same width and would otherwise decode to a wrong timestamp instead of failing. fileio then stamps Timestamp.of(mtime) as you asked, commit afa436c.

This PR is otherwise fileio only, and commit 462d2f5 changes watch.py, which is already merged. I put it here because the cursor dedup in MatchContinuously is not correct without it. If you would rather keep the separation you asked for earlier, I will move it into its own PR and rebase this one on top.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please do not introduce new timestamp coder, we already have many duplications in the code base.

Watch persists the cursor with TimestampCoder, which encodes milliseconds, so the cursor came back up to a millisecond below the outputs it was taken from and the next poll matched them all again

This is a good observation, and suggests an existing bug in timestamp_cursor implementation. There is a risk of a racing condition such that an upcoming element having same timestamp, it might either get dropped, or there are duplicates, depending on how do we round the timestamp.

I think "allowed_lateness" we have discussed earlier is the correct solution here. Instead of introducign a new coder (technically micro precision still has risk on sub-micro rounding), we should always put the elements already seen that having timestamp >= (cursor_in_millis_precision - allowed_lateness_duration) into the restriction. The default allowed_lateness is zero.

Then, on next poll, it automatically dedups the latest results last time already emitted, and can handle upcoming elements that have same timestamp

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, and the coder is gone. You were right that precision was the wrong lever: rounding only moves where the tie falls, it does not remove it.

Deduplication goes back to hashing the output key, and the cursor now bounds that state instead of standing in for it. A key is retired once the greatest emitted event time has moved allowed_lateness past it, so the restriction holds a trailing window rather than every key ever seen. allowed_lateness defaults to zero.

Two things this fixed beyond the tie. match_updated_files was silently ignored under timestamp_cursor, because the cursor branch skipped the key function entirely; both modes now share one path, so the option composes again. And the hash to cursor transition I asked about on the other thread no longer exists: a restriction resumed in cursor mode keeps the hashes its hash rounds recorded, so there is nothing to seed and no poll time read as a last-modified time.

The tie is covered at the Watch level and through MatchContinuously, and I checked both tests fail when the boundary goes back to a strict comparison, with the second file at the same last-modified time dropped silently. GCS reports to the millisecond, so two objects written in the same millisecond reach this.

One question. I put allowed_lateness on Watch only and did not surface it on MatchContinuously, since adding a parameter to a public IO transform seemed yours to decide. For a filesystem clock running behind the local one it is the knob that matters. Would you like it on MatchContinuously as well?

Commits 9df6713 and 9ce7c97.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

One cost of the retention window I should have called out when I proposed it. A key is retired by the event time it was recorded with, so a file modified after the cursor moved past it has nothing left to prove it was seen and is matched a second time, whatever match_updated_files says. The MatchContinuously test I added for the update case never advanced the cursor, so it stopped short of the retirement and read as a guarantee it is not.

Documented on the option and pinned with a Watch test rather than changed. Removing it means recording a key's current event time every round, not just the round it first appeared, which means carrying the already-seen outputs' timestamps through the claim. Happy to do that if you would rather the option not weaken match_updated_files. Commit 0c00e43.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I put allowed_lateness on Watch only and did not surface it on MatchContinuously

This looks fine to me. It's fine filesystem clock and pipeline runtime clock has offset. If modified files timestamp is one-way, it's fine then

Comment thread sdks/python/apache_beam/io/fileio.py Outdated
]
return PollResult.incomplete(outputs).with_watermark(now)
return PollResult.incomplete(
match_result.metadata_list, timestamp=now).with_watermark(now)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We need to consider watermark more carefully here:

There is chance that the remote file system clock and local machine has offset. We don't want to advance the watermark prematurely (if filesystem clock is slower than the machine running SDK harness)

if there is new element seen in this poll, watermark should be no later than min(max(poll timestamp), now); if there isn't new element seen in this poll, set to Now sounds fine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted, with one deviation I would like your call on.

Under timestamp_cursor the poll now returns the newest last-modified time it matched, capped at the poll time, so a filesystem clock behind the local one holds the watermark back and one running ahead cannot carry it past now.

The deviation is the empty poll. Going to now there also advances past a lagging filesystem clock, and I could reproduce a file arriving behind the watermark after a single empty poll, so the poll now returns no watermark and leaves the estimator where it is. That makes the rule purely evidence based, at the cost of the watermark not moving while a directory is quiet. If you would rather have windows keep closing on idle I will put now back.

Commit afa436c, with three poll fn tests for the newest match, the cap, and the empty match.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The adjusted logic could have stale watermark slowly updated sources. When a poll has no new result, watermark stuck. We have a similar issue for PeriodicImpulse: #39026

In FileIO.matchContinuously, are the timestamped elements on their mtime, or the poll time? This affects how watermark should be handled. My first comment assume they were timestamped on mtime, which introduces tricky scenarios. If it's actually on poll time, things would be simpler. Just advance watermark to now() everytime would suffice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They carry their last-modified time under timestamp_cursor, and the poll time otherwise.

That is not only a choice. The retention window compares an output's event time against the cursor, and a poll time would never grow stale, so every re-listed file would look recent and the state would never be bounded. The last-modified time is what lets the state stay bounded, so the simpler poll-time option is not open to us here.

The watermark is back to what you asked for: the newest last-modified time the poll matched, capped at the poll time, and the poll time when the poll matched nothing. I dropped the hold I had added, so a quiet directory no longer stalls the watermark, which was the shape of #39026 you pointed at.

That leaves the case you raised first, a filesystem clock behind the local one. With the watermark at the poll time, a file written while that clock lags arrives behind it. allowed_lateness now covers the deduplication half of that, and I have asked on the other thread whether it should be exposed on MatchContinuously too.

Commit 9ce7c97.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction to what I said last round: a quiet directory did still stall. I only released the watermark on an empty match, and a directory that keeps the same files is not one. Every poll re-lists them, the newest last-modified time never moves, and the watermark sits there while the poll time runs away.

The hold now follows the evidence instead. A poll that turns up a last-modified time newer than any before it has just read the filesystem clock, so the watermark stops there, capped at the poll time. A poll that finds nothing newer takes the poll time. A directory being fed trails the filesystem clock throughout, a quiet one catches up within a poll.

That leaves a file written while the clock lags, in the interval after arrivals stop. allowed_lateness is the knob for it, which is the open question on the other thread.

Two poll fn tests; the release one fails on 9ce7c97 with the watermark still at the file's last-modified time. Commit 0c00e43.

TimestampCoder encodes milliseconds, so a persisted cursor returned up to a
millisecond below the outputs it was taken from and the next poll handed them
out again. A replayed checkpoint primary lost the same precision and repeated
its emission at a different event time.

Both now encode microseconds at a fixed width, and a payload of any other
width is rejected rather than read as a smaller timestamp. The two envelope
tags whose payload changed are retired rather than reused, since a millisecond
payload is the width of a microsecond one and would otherwise decode to a
wrong timestamp.
The poll stamps a match with Timestamp.of(mtime) now that the restriction keeps
microseconds, so the millisecond flooring is gone.

The watermark under timestamp_cursor tracks the filesystem clock: the newest
last-modified time matched, capped at the poll time, and left where it is by a
poll that matches nothing. A clock behind the local one cannot make a later
file late, and one ahead cannot carry the watermark with it.

_mtime_of is renamed _ensure_mtime and no longer takes the option name. The
option's doc states the resolution it compares at and that it needs a fresh
pipeline, since an in-place update would seed the cursor with poll times the
old state recorded.
A cursor alone cannot tell two outputs at one event time apart: comparing
strictly drops the second, comparing loosely repeats both. Rounding only moves
where that tie falls, so the microsecond coder goes with it.

Dedup goes back to hashing the output key, and the cursor now bounds that state
rather than standing in for it. A key is retired once the greatest emitted
event time has moved allowed_lateness past it, so the restriction holds a
trailing window instead of every key ever seen. allowed_lateness defaults to
zero and widens the window for a source whose outputs arrive out of order.

The cursor is orthogonal to the key, so it no longer conflicts with
output_key_fn, and a restriction resumed in cursor mode keeps the hashes its
hash rounds recorded rather than seeding a cursor from them.
The cursor bounds the deduplication state rather than replacing the key, so
the transform passes the same key function in both modes and
match_updated_files decides whether a changed file counts as new again.

The watermark under timestamp_cursor is the newest last-modified time matched,
capped at the poll time, and the poll time when nothing matched, so a quiet
directory no longer holds it back.
Holding the watermark at the newest last-modified time matched stalls a
directory that is quiet rather than empty: every poll re-lists the same files,
so the newest one never moves and the watermark sits at its last-modified time
while the poll time runs away from it. Only an empty match released it, which
is not the case that stalls.

The hold now follows the evidence. A poll that turns up a last-modified time
newer than any before it has just read the filesystem clock, so the watermark
stops there and files still in flight behind a clock that lags the local one
are not late. A poll that finds nothing newer has no fresh reading to go on, so
the watermark takes the poll time and event-time windows keep closing. A
continuously fed directory therefore trails the filesystem clock throughout,
and a quiet one catches up.

Also records what bounding the deduplication state costs: a file modified after
its id was retired reads as new and is matched a second time, whatever
match_updated_files says. The MatchContinuously test that covered the update
case never advanced the cursor, so it did not reach the retirement; the Watch
test added here pins it.
@Eliaaazzz
Eliaaazzz force-pushed the matchcontinuously-on-watch branch from 0c00e43 to 0526f7d Compare August 13, 2026 05:16
Holding the watermark at the newest last-modified time matched stalls a
directory that is quiet rather than empty: every poll re-lists the same files,
so the newest one never moves and the watermark sits at its last-modified time
while the poll time runs away from it. Only an empty match released it, which
is not the case that stalls.

The hold now follows the evidence. A poll that turns up a last-modified time
newer than any before it has just read the filesystem clock, so the watermark
stops there and files still in flight behind a clock that lags the local one
are not late. A poll that finds nothing newer has no fresh reading to go on, so
the watermark takes the poll time and event-time windows keep closing. A
continuously fed directory therefore trails the filesystem clock throughout,
and a quiet one catches up.

Also records what bounding the deduplication state costs: a file modified after
its id was retired reads as new and is matched a second time, whatever
match_updated_files says. The MatchContinuously test that covered the update
case never advanced the cursor, so it did not reach the retirement; the Watch
test added here pins it.
@Eliaaazzz

Copy link
Copy Markdown
Contributor Author

Apologies for the force push earlier. I realized this conflicts with the requested review
workflow.

For auditability: the force push only changed commit metadata/trailers. The reviewed code tree from 0c00e43 and the current head
626ee74 are identical (git diff 0c00e43b93 626ee74c97 is
empty).

I then avoided further history rewriting and pushed two normal commits:

The existing review threads are still preserved in the conversation/outdated threads. I won't force-push this PR branch again.

@Eliaaazzz

Copy link
Copy Markdown
Contributor Author

I added a reviewer guide at the top of the PR description to make the current review path easier to follow after the history correction. It includes the audit note, current entry points, and the remaining reviewer decisions.

@Eliaaazzz

Eliaaazzz commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Superseded. This run predates 7669d4abbe9, which keys the cursor on the path and the last-modified time. The cursor column below shows the old path-only keying, where an update was dropped while its key was retained. Its job is cancelled. See #39461 (comment) for the current result.

Ran the Dataflow integration check you asked for. The job is still running, so you can inspect it live rather than only reading numbers from me.

Job: 2026-08-14_04_42_03-12041500729043885033

Markers: open in Cloud Logging, or paste

resource.labels.job_id="2026-08-14_04_42_03-12041500729043885033"
jsonPayload.message:"WATCH_EMIT"

Watched prefix: gs://io-performance-temp/watch-live-0814-214142/. It is still being polled every 15s, so if you drop or overwrite a file there you will see all three branches react within a poll or two. Anything you add lands after the timeline below and will also advance the cursor.

MatchContinuously.expand takes pbegin, so it is a root transform and three of them live in one pipeline as three independent roots, each with its own Watch SDF and its own restriction, all reading the same prefix. That puts all three modes in a single job on one worker and one clock, started together. Each marker carries its mode so the one log stream splits apart.

branch options
defaults match_updated_files=False, timestamp_cursor=False
updated match_updated_files=True
cursor timestamp_cursor=True

Project apache-beam-testing, region us-east1, Runner v2, streaming, poll interval 15s, one worker. SDK 2.77.0.dev built from this branch and passed with --sdk_location: source commit 626ee74c97ee9c225edd6463cfc56c5e5760bfc8 on a clean tree, sdist sha256 a0c524840e2057ee6c5d087fbaad63e2eb125679ecf9a79f8d4f97d9fb2dded8.

Design

The sequence is built so the outcome isolates key retirement. Only f.txt is seeded, so the cursor lands exactly on its last-modified time and its key stays retained rather than being retired by a newer file.

UTC step purpose
11:41:43 seed f.txt the only file, so its key stays alive
11:51:52 all three branches have matched f.txt
11:52:17 overwrite f.txt key still retained, cursor must not match it again
11:53:22 add sentinelA.txt every branch must match it, proving each polled past the overwrite, and it advances the cursor so f.txt's key is retired
11:55:24 overwrite f.txt again key now retired, cursor must match it again
11:56:29 add sentinelB.txt every branch must match it, proving each polled past the second overwrite

The two overwrites are the same operation on the same path. The only thing that differs is whether the key was still retained.

Result

step defaults updated cursor
seed f.txt match match match
overwrite 1, key retained no match match no match
sentinelA.txt match match match
overwrite 2, key retired no match match match
sentinelB.txt match match match

Totals: f.txt 1 / 3 / 2, sentinelA.txt 1 / 1 / 1, sentinelB.txt 1 / 1 / 1.

  • defaults deduplicate by path, so neither overwrite is matched again. Both sentinels are matched after each overwrite, so the two absences come from deduplication rather than from a branch that stopped polling.
  • match_updated_files=True deduplicates by path and last-modified time, so both overwrites are matched again.
  • timestamp_cursor=True deduplicates by path. It does not match the first overwrite, because the key is still retained, and does match the second, after sentinelA.txt moved the cursor past f.txt. That is the documented cost of bounding the state. The first overwrite also rules out the cursor mode keying on the last-modified time behind the scenes.

Because the branches share a worker, the three matched each file within 79ms, 71ms and 682ms of one another, so the comparison is not sensitive to poll skew between branches.

Raw markers, prefix stripped.

--- defaults ---
WATCH_EMIT  plain    1786708134016  f.txt          Timestamp(1786708133.646605)  1786707706.686
WATCH_EMIT  plain    1786708407638  sentinelA.txt  Timestamp(1786708407.349384)  1786708401.883
WATCH_EMIT  plain    1786708600285  sentinelB.txt  Timestamp(1786708600.075523)  1786708588.886

--- updated ---
WATCH_EMIT  updated  1786708134081  f.txt          Timestamp(1786708133.870010)  1786707706.686
WATCH_EMIT  updated  1786708343240  f.txt          Timestamp(1786708342.946708)  1786708336.918
WATCH_EMIT  updated  1786708407622  sentinelA.txt  Timestamp(1786708407.356391)  1786708401.883
WATCH_EMIT  updated  1786708535720  f.txt          Timestamp(1786708535.415569)  1786708523.961
WATCH_EMIT  updated  1786708599603  sentinelB.txt  Timestamp(1786708599.466617)  1786708588.886

--- cursor ---
WATCH_EMIT  cursor   1786708134002  f.txt          Timestamp(1786707706.686000)  1786707706.686
WATCH_EMIT  cursor   1786708407567  sentinelA.txt  Timestamp(1786708401.883000)  1786708401.883
WATCH_EMIT  cursor   1786708535671  f.txt          Timestamp(1786708523.961000)  1786708523.961
WATCH_EMIT  cursor   1786708600241  sentinelB.txt  Timestamp(1786708588.886000)  1786708588.886

Event times

Defaults and match_updated_files stamp a match with the poll time; timestamp_cursor stamps it with the file's last-modified time, visible above where event_time equals mtime exactly.

Scope

Each marker is written by a DoFn before it yields, and the pipeline has no durable sink, so these are counts of how many times each file was processed into a log marker. That is not a transactional acknowledgement and does not on its own establish exactly-once delivery. Nothing here exercises restart or pipeline update.

Tell me when you have finished looking and I will cancel the job.

Keying on the path alone left an update out while its key was retained and
let one through once the cursor had retired the key, so whether an updated
file matched depended on cursor timing.

The cursor now keys on the path and the last-modified time. An update is a
new key and is always matched. Retiring a key can no longer duplicate a
match either, since the only file that would recreate a retired key carries
the same last-modified time and is skipped by the same mark.
@Eliaaazzz

Copy link
Copy Markdown
Contributor Author

Good catch, and it was worse than an edge case. Fixed in 7669d4abbe9 and re-validated below. The job is running, so you can inspect it live.

The bug

The cursor keyed on the path alone:

output_key_fn=(_file_path_and_mtime_key if self.match_upd else _file_path_key),

So an update was matched or not depending on whether the cursor had happened to retire the key yet. While the key was retained the update was dropped, which is what you saw. Once a newer file moved the cursor past it, the same update went through. The outcome depended on cursor timing rather than on anything the user asked for.

The fix

The cursor now keys on the path and the last-modified time, so an update is a new key and is always matched.

It also removes the weakness I had documented as the cost of bounding the state. A retired key can no longer produce a duplicate: the only file that would recreate key (path, m) carries last-modified time m, and m is below the retention floor precisely when that key was retired, so the same mark that retired it also skips the file. Retirement and the floor use one comparison, so they cannot disagree.

What remains is the intended limitation: a file whose last-modified time is already below the floor when it first appears is skipped, which is the backfill and copy-preserving-mtime case, and is what allowed_lateness widens.

One consequence worth your call: timestamp_cursor=True now implies the match_updated_files key, so match_updated_files=False no longer changes behaviour under the cursor. I documented that on the option rather than raising on the combination. Happy to reject it loudly instead if you would prefer.

Re-validation

Job: 2026-08-14_16_59_38-13437278132120143817

Markers: open in Cloud Logging, or paste

resource.labels.job_id="2026-08-14_16_59_38-13437278132120143817"
jsonPayload.message:"WATCH_EMIT"

Watched prefix gs://io-performance-temp/watch-fix-0815-095913/, still polled every 15s, so dropping or overwriting a file there shows all three branches reacting within a poll or two.

Three modes run as three roots in one pipeline, each with its own Watch SDF and restriction, on one worker and one clock. Project apache-beam-testing, us-east1, Runner v2, streaming, poll interval 15s, one worker. SDK 2.77.0.dev from commit 7669d4abbe9, clean tree, sdist sha256 808a0667972f761ec907812cd0ab754cdc73b81ea27184d5ab6b443f9b374580.

Same sequence as before. Only f.txt is seeded so the cursor sits on its last-modified time and its key stays retained. It is overwritten once while nothing newer exists, then sentinelA.txt advances the cursor and retires the key, then it is overwritten again, then sentinelB.txt.

step defaults updated cursor
seed f.txt match match match
overwrite 1, key retained no match match match
sentinelA.txt match match match
overwrite 2, key retired no match match match
sentinelB.txt match match match

Totals: f.txt 1 / 3 / 3, sentinelA.txt 1 / 1 / 1, sentinelB.txt 1 / 1 / 1.

The cursor column now equals the match_updated_files column, which is the point: the cursor changes what the state costs, not which files match. Both sentinels are matched after their overwrite, so the defaults non-matches come from deduplication and not from a branch that stopped polling. Branch spread per file was 218ms, 103ms, 182ms, 25ms and 280ms.

--- defaults ---
WATCH_EMIT  plain    1786752410976  f.txt          Timestamp(1786752410.468923)  1786751959.742
WATCH_EMIT  plain    1786753058345  sentinelA.txt  Timestamp(1786753058.181599)  1786753044.357
WATCH_EMIT  plain    1786753398308  sentinelB.txt  Timestamp(1786753398.175815)  1786753396.126

--- updated ---
WATCH_EMIT  updated  1786752410979  f.txt          Timestamp(1786752410.676979)  1786751959.742
WATCH_EMIT  updated  1786752993941  f.txt          Timestamp(1786752993.611642)  1786752978.849
WATCH_EMIT  updated  1786753058419  sentinelA.txt  Timestamp(1786753058.174470)  1786753044.357
WATCH_EMIT  updated  1786753334718  f.txt          Timestamp(1786753334.489870)  1786753330.68
WATCH_EMIT  updated  1786753398579  sentinelB.txt  Timestamp(1786753398.365537)  1786753396.126

--- cursor ---
WATCH_EMIT  cursor   1786752410761  f.txt          Timestamp(1786751959.742000)  1786751959.742
WATCH_EMIT  cursor   1786752993838  f.txt          Timestamp(1786752978.849000)  1786752978.849
WATCH_EMIT  cursor   1786753058237  sentinelA.txt  Timestamp(1786753044.357000)  1786753044.357
WATCH_EMIT  cursor   1786753334693  f.txt          Timestamp(1786753330.680000)  1786753330.68
WATCH_EMIT  cursor   1786753398588  sentinelB.txt  Timestamp(1786753396.126000)  1786753396.126

Defaults and match_updated_files stamp a match with the poll time; the cursor stamps it with the last-modified time, visible where event_time equals mtime.

Unit coverage: test_timestamp_cursor_emits_an_updated_file replaces the test that pinned the old behaviour. It fails on the previous key function with one match instead of two. fileio_test.py and watch_test.py are 97 passed, 2 skipped.

As before, each marker is a DoFn log line with no durable sink, so these are processing counts rather than an exactly-once proof, and nothing here exercises restart or pipeline update. The earlier job showing the old behaviour is cancelled so it cannot be mistaken for current.

file with timestamp changes.
apply_windowing: Whether each element should be assigned to
individual window. If false, all elements will reside in global window.
timestamp_cursor: (When has_deduplication is set to True) bound the

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll cut it down this

Comment on lines +449 to +452
if timestamp_cursor and not has_deduplication:
raise ValueError(
'MatchContinuously(timestamp_cursor=True) deduplicates, so it '
'requires has_deduplication=True.')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if timestamp_cursor and not has_deduplication:
raise ValueError(
'MatchContinuously(timestamp_cursor=True) deduplicates, so it '
'requires has_deduplication=True.')
if timestamp_cursor:
if not has_deduplication:
raise ValueError(
'MatchContinuously(timestamp_cursor=True) deduplicates, so it '
'requires has_deduplication=True.')
if not match_updated_files:
_LOGGER.warning("timestamp_cursor implies match_upd, set match_upd=True")
self.match_upd = True

termination=termination,
output_key_fn=(
_file_path_and_mtime_key if self.match_upd else _file_path_key),
_file_path_and_mtime_key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We don't need to change this line if we guarantee match_upd=True when timestamp_cursor is set

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants