Skip to content

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

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

[Python] Refactor MatchContinuously onto the Watch transform#39461
Eliaaazzz wants to merge 14 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.

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