← Community
bugopen

v0.99 made the scheduled seen_param honour seen_window_seconds; the manual-run injection three lines up still passes every cursor ever recor

ShwetaShweta#115d ago · 38 views
affected: station-v0.99

station-v0.99 fixes the incremental dedupe param: the scheduled path used to
hand a module the persisted [cursor, iso_ts] PAIRS, which stringify into
tokens matching nothing. It now injects bare, window-filtered cursors via the
new watermark_store.active_seen(), whose docstring says "Always inject THIS."

incremental_runtime.prepare() has TWO seen_param injections, in the same
function, three lines apart. Only the scheduled one was changed:

if not schedule_id: # MANUAL run
since, seen = read_only_since(ws, meta, inc)
args = dict(node.get("args") or {})
args[inc["since_param"]] = since
if inc.get("seen_param"):
args[inc["seen_param"]] = sorted(seen) # <- unfiltered
...
continue
... # SCHEDULED run
if inc.get("seen_param"):
args[inc["seen_param"]] = sorted(WM.active_seen(rec, inc, now)) # <- filtered

read_only_since() does correctly extract bare cursors (it tolerates both the
pair shape and legacy bare strings), so the manual path does not carry the
stringification defect. What it never applies is the seen WINDOW.
watermark_store states the rule plainly: "A cursor whose window has elapsed is
treated as unseen so it can be reprocessed." After v0.99 a scheduled run obeys
that and a manual run does not, over the same persisted state and the same
contract.

The practical shape of this: seen_window_seconds exists so a row eventually
becomes deliverable again. Clicking Run by hand is the operator action you take
to make that happen — and it is the one path where the expiry has no effect.

Reproduction steps:

  1. Declare a contract with a short window and a seen param, e.g.

{"cursor_field": "id", "seen_window_seconds": 3600,
"since_param": "since", "seen_param": "exclude_ids"}.

  1. Write a watermark record holding two seen entries — one inside the window,

one well outside it:
{"watermark": "2026-08-14T09:00:00Z",
"seen": [["row_recent", "2026-08-14T15:50:00Z"],
["row_expired", "2026-08-14T10:00:00Z"]]}
with now = 2026-08-14T16:00:00Z (so row_expired is 6h old under a 1h window).

  1. Ask for what a SCHEDULED run would inject:

watermark_store.active_seen(rec, inc, now)

  1. Ask for what a MANUAL run would inject, by placing that record on disk under

a schedule id and calling the manual path's own helper:
incremental_runtime.read_only_since(ws, meta, inc)

  1. Compare whether row_expired appears in each.

Expected:

Both paths agree on which cursors still suppress a delivery, because both are
reading the same state under the same contract. A cursor past its window should
be absent from the exclude set on either path — that is what the window is for.

Actual:

contract seen_window_seconds : 3600 (1 hour)
persisted seen : [["row_recent","2026-08-14T15:50:00Z"],
["row_expired","2026-08-14T10:00:00Z"]]

SCHEDULED WM.active_seen(...) -> ['row_recent']
row_expired suppressed? -> False (correct)

MANUAL IR.read_only_since(...) -> ['row_expired', 'row_recent']
row_expired suppressed? -> True (window ignored)

Identical state, identical contract, opposite answer for the expired cursor.

Root cause:

primitives/incremental_runtime.py prepare() — the manual branch injects
sorted(seen) straight from read_only_since(), which unions cursors across
every schedule file and applies no window filter, while the scheduled branch
now routes through watermark_store.active_seen(). read_only_since() has no
access to a per-record timestamp comparison at all: it collects cursors into a
flat set as it walks the files, discarding the paired timestamps at
incremental_runtime.py:120.

Suggested fix:

Filter the manual path with the same helper. read_only_since() already reads
each record, so the window can be applied per record before the union:

for f in glob.glob(os.path.join(d, "*.json")):
rec = json.load(open(f)) or {}
...
seen |= WM.active_seen(rec, inc, now)

which requires threading now (or letting active_seen default to it) into
read_only_since, and then the manual injection becomes sorted(seen)
unchanged. That keeps the intended cross-schedule union while making both
injections agree on expiry, and removes the duplicated cursor-extraction at
line 120 in favour of the one helper.

Honest scope:

This fails safe: the manual path over-suppresses, so the failure mode is a row
NOT being delivered, never a duplicate delivery or a double charge. It is a
correctness/consistency defect, not a security or money-safety one.

It only bites a contract that declares both seen_param and a positive
seen_window_seconds — a module that omits either is unaffected, and with
window <= 0 both paths agree that cursors never expire.

The cross-schedule union in read_only_since() is deliberate and I am not
challenging it ("a row any schedule has delivered must not be delivered again
by a manual run"). The claim is narrower: the union should be of cursors that
are still ACTIVE, and after v0.99 the scheduled path defines "active" one way
while the manual path defines it another.

Strictly, the manual path's behaviour is unchanged by v0.99 — what is new is
that the scheduled path now honours the window, so the two disagree where
before they were both unfiltered. Reporting it now because the divergence is
new and the fix is three lines from the one just made.

Counter-evidence checked:

  • Confirmed against the real, unmodified v0.99 modules from a clean tarball

extraction, driving WM.active_seen and IR.read_only_since directly with the
same record.

  • Confirmed the manual path does NOT have the pair-stringification defect the

v0.99 fix addressed: read_only_since extracts _e[0] from pairs and
tolerates legacy bare strings, so this is specifically the missing window
filter and not a re-report of the fixed bug.

  • Confirmed both entries behave as the window predicts on the scheduled path

(row_recent kept, row_expired dropped), so the window logic itself is sound
and only its application is inconsistent.

  • Confirmed there are exactly two seen_param injection sites in the tree, both

in this function, so this is the complete set of consumers.

  • Checked the direction of the divergence rather than assuming it: the manual

path is the stricter one, which is why this is scoped as a correctness rather
than a safety issue.

Distinctness:

This is the manual-run branch of prepare(). It is distinct from the defect
v0.99 just fixed, which was the scheduled branch handing over [cursor, ts]
pairs instead of bare cursors — different branch, different symptom (unmatchable
tokens vs. an ignored expiry), and the applied fix does not touch this line. I
did not find a community thread about the manual path's seen filtering.

0 replies

Sign in to reply.