Skip to content

fix: keep the dedup hash for its full window, independent of flush timing - #375

Closed
yosriady wants to merge 14 commits into
mainfrom
fix/dedup-window-signed-wallet-snapshot
Closed

fix: keep the dedup hash for its full window, independent of flush timing#375
yosriady wants to merge 14 commits into
mainfrom
fix/dedup-window-signed-wallet-snapshot

Conversation

@yosriady

@yosriady yosriady commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #372.

Why

The queue drops an event whose payload is identical to one accepted recently. That guard exists for double-fire-prone UIs: React strict/dev double effects, double click handlers, a track() in a re-run effect. Until 1.35.0 the hash lived as long as its event sat in the queue, in practice until the batch timer (30s) or the batch threshold. #326 then started sending the very first event of a page load immediately, and the hash left with it. So the first event of every page load had no duplicate protection at all, and the suppression window for every other event depended on when the last flush happened to run.

What changed (src/queue/EventQueue.ts)

  • Rolling 60s window, independent of flush. payloadHashes is now a Map<fingerprint, expiresAt>. An accepted event suppresses identical ones for 60 seconds from acceptance; flush no longer removes entries.
  • Timestamp-free fingerprint. Duplicates are judged by a sha256 of the event without original_timestamp. message_id is unchanged: it keeps its minute-truncated timestamp because it is the event's identity on the wire and must never collide for events a minute apart. Judging by it meant a double-fire straddling a UTC minute boundary (:59.999 / :00.001) was sent twice (review finding).
  • Front-only prune. Entries are in insertion order and each expires a fixed interval after insertion, so the prune stops at the first live entry: each expired entry is visited once in its life, not once per enqueue (review finding). Manual iterator because for...of over a Map does not compile under the ES5 target and Map.forEach cannot stop early.
  • Clock that never steps back and counts suspension. The window is timed by the larger of monotonic elapsed (performance.now()) and wall elapsed, clamped per instance to never decrease. Backward wall steps are absorbed, so insertion order stays expiry order for the prune; forward steps and OS suspension expire entries on time or early, the safe direction for a duplicate guard (review findings).
  • Failed sends release their fingerprints. A batch whose send fails (retries exhausted or a non-retryable response) releases its items' entries so an app retry after the error callback is accepted; delivered batches keep theirs. Batches abandoned on a mid-flush consent withdrawal are released too. Release is by a unique per-acceptance token, so a send that outlives the window through backoff, or is cut short by clear(), can never release a newer acceptance of the same key (review findings).
  • Consent and clear() re-checked across every gap. enqueue() re-checks consent after its hash awaits, and clear() bumps a generation counter that enqueue() and flush() capture before their awaits: an opt-out/opt-in round trip inside a gap still drops the pre-withdrawal event, and abandons (and releases) the split batches behind an in-flight one. flush() also repeats its gates after waiting on a pending flush, so it never POSTs an empty batch (review findings).
  • clear() forgets only what it abandons. Under a rolling window the map also holds delivered and in-flight events; clear() (recoverable, consent withdrawal) releases only the buffered items it drops, so a copy of a delivered event is still a duplicate after an opt-out/opt-in round trip. close() (terminal) forgets everything (review finding).

Bundle budget

The change adds about 0.3 kB brotlied on top of #376: 59.04 kB against the 58.75 kB budget #376 set. Raised to 59.5 kB, following the steps of #364, #367, #368 and #376.

Behaviour note

An identical payload is now suppressed for 60s, always. Before, it was suppressed "until the next flush", which was timing dependent. Autocaptured wallet events are unaffected in practice: each carries a hash, batch id, or distinct call data. The one visible case is a batch retried with the exact same calls inside a minute, whose transaction:started rows (no id yet) are deduped. That already happened before this change whenever no flush ran in between; now it is deterministic. The examples e2e harness sent the same batch three times in one second and was updated for this: getformo/examples#297.

Tests (test/lib/queue/EventQueue.spec.ts)

22 new cases covering: duplicate after the immediate first flush; after a later flush inside the window; accepted again after 60s with the same timestamp; duplicate of a still-queued event; double-fire across a UTC minute boundary; message ids distinct across minutes while content dedup holds; front-only prune (exact survivors, expired key accepted again); consent withdrawn while hashing (and withdrawn-then-restored) does not buffer; failed send releases its fingerprint, delivered send keeps it; an old send failing after the window does not release a newer acceptance; batches abandoned on a mid-flush opt-out, and on an opt-out/opt-in round trip, are released and never sent; a delivered event keeps suppressing across clear(); close() forgets all; no empty POST when clear() empties the queue mid-wait; consent lost while flush() waits drops the buffer; drainAll does not wait on a pending flush; forward wall step expires, backward step does not reopen, a corrected forward jump keeps real pace; the clock without performance.now. Existing fixtures that relied on hashes vanishing at flush now use distinct events and assert the buffer length.

Coverage of src/queue/EventQueue.ts (c8): 95.7% statements, 89.9% branches. Every uncovered line is pre-existing (defensive outer catch, retry-status branches, page-leave listeners); all code new in this PR is covered.

Review

  • Codex CLI (gpt-5.5, high): ten passes, each on the then-current diff. Findings fixed in order: post-await consent gate (9841643), fallback-clock ordering (1ae87f2), release-by-key race (f3fd1a8), token uniqueness (5ffd6ca), corrected forward jump (a81c122), clear() wiping delivered fingerprints (3ea98d9), opt-out/opt-in round trip inside enqueue() (437a913) and inside a split flush plus the empty-POST gap (3e29f15), a test that did not sample the jumped clock (d477dcb). Pass 10: no majors.
  • PR bots (Codex connector, cubic): minute boundary and full-map scan (a6c6085), expiry ordering under clock steps (9841643, b22bf49), retries after a failed send (b22bf49, f3fd1a8, 5ffd6ca), suspension (b22bf49), corrected forward jump (a81c122). All answered on the threads; one (a prune timer for idle pages) declined with numbers.

Verification

The signed wallet snapshot for #373 was prototyped on this branch and removed before merge; #373 is closed as deferred with the implementation referenced.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa4b15d944

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/queue/EventQueue.ts
// The hash folds in the timestamp truncated to the minute, so two identical
// events can only collide within one minute; holding a hash any longer than
// that cannot suppress anything.
const DEDUP_WINDOW_MS = 1_000 * 60; // 1 MINUTE

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make the deduplication window independent of minute boundaries

When two otherwise identical events are created on opposite sides of a UTC minute boundary—for example at 12:34:59.999 and 12:35:00.001generateMessageId() gives them different hashes because it includes the minute-truncated timestamp. The map therefore accepts the second event even though the first entry's 60-second expiry remains open, so the new rolling deduplication window still misses double-fires near every minute boundary. Use a timestamp-independent fingerprint or account for adjacent minute buckets.

Useful? React with 👍 / 👎.

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.

Valid, fixed in a6c6085. Duplicates are now judged by a sha256 of the event without its timestamp (generateDedupKey), and message_id keeps its minute-truncated timestamp because it is the wire identity and must never collide for events a minute apart. Pinning test: "catches a double-fire that straddles a UTC minute boundary" (:59.999 and :00.001, same content, different message ids, second dropped), plus "keeps message ids distinct across minutes while deduping by content".

Comment thread src/queue/EventQueue.ts Outdated
Comment on lines +473 to +475
this.payloadHashes.forEach((expiresAt, id) => {
if (expiresAt <= now) this.payloadHashes.delete(id);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid scanning the full deduplication window per event

On pages producing many distinct events, every enqueue traverses all hashes accepted during the preceding minute, so accepting n events performs roughly 1 + … + n expiry checks even though flushes no longer remove entries. High-frequency autocapture can therefore introduce quadratic main-thread work; expiry should be pruned with an ordered queue/cursor or another structure that visits each expired entry only once.

Useful? React with 👍 / 👎.

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.

Valid, fixed in a6c6085. Entries are in insertion order and each expires a fixed interval after insertion, so pruneExpired now walks from the front and stops at the first live entry: each expired entry is visited once in its life, not once per enqueue. 9841643 and 1ae87f2 make the ordering assumption hold under wall-clock steps (performance.now() where available, clamped per instance on the Date.now() fallback). Pinning test: "prunes only from the front and stops at the first live entry".

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 7 files

You’re at about 96% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/queue/EventQueue.ts Outdated
@yosriady
yosriady force-pushed the fix/dedup-window-signed-wallet-snapshot branch from fa4b15d to 0916447 Compare August 27, 2026 09:59
@yosriady yosriady changed the title fix: time-based dedup window and signed wallet snapshot cookie fix: keep the dedup hash for its full window, independent of flush timing Aug 27, 2026
yosriady added a commit that referenced this pull request Aug 27, 2026
…the front

Review follow-ups on #375.

The dedup key was the message id, which folds in the minute-truncated
timestamp because it is the event's identity on the wire. A double-fire
straddling a UTC minute boundary (:59.999 and :00.001) therefore got two
ids and both went out. Duplicates are now judged by a sha256 of the event
without its timestamp; message_id is unchanged.

Pruning walked the whole map on every enqueue. Entries are in insertion
order and each expires a fixed interval after insertion, so the prune now
stops at the first live entry: each expired entry is visited once in its
life. Manual iterator because for..of over a Map does not compile under
the ES5 target and Map.forEach cannot stop early.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6c6085c2b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/queue/EventQueue.ts Outdated
for (let step = entries.next(); !step.done; step = entries.next()) {
const key = step.value[0];
const expiresAt = step.value[1];
if (expiresAt > now) break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve expiry ordering across wall-clock changes

When the system clock moves backward between two accepted events, insertion order no longer matches expiry order. After the later event's 60-second window expires, this break can stop at an older pre-adjustment entry whose expiry is still in the future, leaving the expired key in the map; isDuplicate() then drops legitimate repetitions until the clock reaches that older expiry, potentially extending suppression by the full clock adjustment. Use a monotonic clock or an expiry-ordered structure that remains valid across wall-clock corrections.

Useful? React with 👍 / 👎.

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.

Valid, fixed in 9841643 and b22bf49. The window is timed by elapsedNow(): the larger of monotonic elapsed (performance.now) and wall elapsed, clamped per instance to never decrease. A backward wall step is absorbed (the other source and the clamp hold), so insertion order stays expiry order and the front-prune assumption holds. Pinning test: "counts a forward wall-clock step toward expiry, and ignores a backward one".

Comment thread src/queue/EventQueue.ts
Comment on lines +321 to +322
// Decrement the running byte total by exactly what left the queue. The
// dedup hashes stay: they expire on their own clock, not on flush.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow retries after a failed send

Because fingerprints now survive every flush, they also survive requests that exhaust the built-in retries or receive a non-retryable response. If the per-event callback or application retries the identical event within 60 seconds, enqueue() classifies it as a duplicate and silently drops it, converting a recoverable delivery failure into guaranteed event loss; previously the hash was removed when the item left the queue. Remove or invalidate fingerprints for items whose batch fails while retaining them for successfully delivered items.

Useful? React with 👍 / 👎.

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.

Valid, fixed in b22bf49, f3fd1a8 and 5ffd6ca. A batch whose send fails (retries exhausted or non-retryable) now releases its items' fingerprints so an app retry after the error callback is accepted; delivered batches keep theirs. Release is by a unique per-acceptance token, so a send that outlives the window through backoff (or is cut short by clear()) can never release a newer acceptance of the same key. Batches abandoned on a mid-flush consent withdrawal are released the same way. Pinning tests: "releases the fingerprint of an event whose send failed", "keeps the fingerprint of an event whose send succeeded", "does not release a newer fingerprint when an old send fails after the window", "does not release a same-instant re-acceptance after clear()", "releases the fingerprints of batches abandoned when consent is withdrawn mid-flush".

yosriady added a commit that referenced this pull request Aug 27, 2026
…notonically

Review follow-ups on #375.

enqueue() re-checked close() after its hash awaits but not consent, so a
withdrawal in that gap could still buffer the event (the flush gate would
then drop it, but the contract of this path is to never buffer after
withdrawal). The consent gate is now repeated after the awaits.

The dedup window used Date.now(). A wall-clock step could expire entries
early or, stepping backwards, file a new entry behind older ones with later
expiries, out of reach of the front-prune. The window now uses
performance.now() where available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 984164368b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/queue/EventQueue.ts
*/
const elapsedNow = (): number =>
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count suspended time toward deduplication expiry

On platforms where performance.now() does not advance while the OS is suspended, an event accepted before a laptop or mobile device sleeps remains in the deduplication window after wake even if hours of real time passed. An identical legitimate event is then dropped until the remaining 60 seconds of active monotonic time elapses, so suppression can last arbitrarily longer than the documented minute; use or reconcile with a clock that accounts for suspension while still handling wall-clock corrections.

Useful? React with 👍 / 👎.

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.

Valid, fixed in b22bf49. elapsedNow() takes the larger of monotonic elapsed and wall elapsed (clamped non-decreasing), so suspension counts toward expiry: a device that sleeps past the window wakes with the entry expired. Forward wall steps expire early, which is the safe direction for a duplicate guard. Pinning test: "counts a forward wall-clock step toward expiry, and ignores a backward one" (forward step with no monotonic time elapsed expires the entry).

yosriady added a commit that referenced this pull request Aug 27, 2026
…the dedup window

Review follow-ups on #375.

Fingerprints now outlive the flush, so they also outlived a send whose
retries were exhausted or whose response was not retryable. An app that
sent the same event again after the error callback had that retry dropped
as a double-fire. A failed batch now releases its fingerprints; a delivered
one keeps them.

performance.now() stops during OS suspension on some platforms, so a
device that slept mid-window woke still inside it. The window clock now
takes the larger of monotonic elapsed and wall elapsed, clamped to never
decrease: forward wall steps and suspension expire entries on time or
early, backward steps are absorbed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b22bf49330

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/queue/EventQueue.ts Outdated
// retryable. Release its fingerprints so the app can send the same
// event again after the error callback, instead of having that
// retry classified as a double-fire and dropped for a minute.
batch.items.forEach(({ dedupKey }) => this.payloadHashes.delete(dedupKey));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Delete only the failed fingerprint generation

The new failure-release code unconditionally deletes the current entry for this content, even when it belongs to a newer event. If a request remains pending for over 60 seconds (the fetch wrapper has no timeout), the same content can legitimately expire and be accepted again; when the older request later fails, this line removes the newer event's fresh suppression window, allowing an immediate duplicate through. Associate the stored expiry/generation with each queue item and delete only when the map still contains that generation.

Useful? React with 👍 / 👎.

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.

Valid, fixed in f3fd1a8 and 5ffd6ca. Each queue item records the token its acceptance was given and releaseFingerprints deletes only on a token match, so an old send that fails after the same content was accepted again leaves the newer entry alone. Pinning test: "does not release a newer fingerprint when an old send fails after the window".

Comment thread src/queue/EventQueue.ts
Comment on lines +515 to +516
const now = this.elapsedNow();
this.pruneExpired(now);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Schedule cleanup when the dedup window expires

Expired fingerprints are pruned only when another event reaches isDuplicate(). If a high-volume page emits a burst and then becomes idle, every hash from that burst remains retained for the lifetime of this queue even though none can affect deduplication after 60 seconds; unlike the old flush-coupled set, a flush does not release them. Schedule cleanup for the earliest expiry (or prune from an existing timer/flush path) so an idle long-lived page does not permanently retain its peak one-minute event volume.

Useful? React with 👍 / 👎.

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.

Declined, with the numbers. Retention is bounded to one minute of accepted events: an entry is a 64-char hex key plus a two-field object, about 200 B, so a burst of 1000 events in a minute holds about 200 kB until the next enqueue, and it is released in full by clear() (consent withdrawal) and close() (teardown). Analytics pages do not sustain thousands of distinct events per minute. A dedicated expiry timer would buy back that bounded memory at the cost of a lifetime hazard: a pending timer keeps a Node process alive (the e2e harnesses run the SDK in Node), so it would need unref plus clearing on every teardown path, which is more surface than the retention justifies. Happy to add it if a real page shows the retention mattering.

yosriady added a commit that referenced this pull request Aug 27, 2026
…ndoned on opt-out

Review follow-ups on #375.

A failed batch released its fingerprints by key alone. A send can outlive
the window through retry backoff; by then the same event may have expired,
been accepted again and be in flight under the same key, and the old
failure would release that newer entry. Each item now records the expiry
it was accepted with and releases only on a match.

Batches abandoned when consent is withdrawn mid-flush were never sent but
kept their fingerprints, so an app retry inside the window after opt-in
was dropped. They are released before the break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f3fd1a8dc3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/queue/EventQueue.ts Outdated
Comment on lines +545 to +546
if (this.payloadHashes.get(item.dedupKey) === item.dedupExpiresAt) {
this.payloadHashes.delete(item.dedupKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Assign unique generation tokens to fingerprints

The expiry value is not a unique generation: clear() is explicitly reusable and can run while a flush is in flight, so on a coarse or frozen clock, immediately re-enqueuing the same event records the same dedupExpiresAt as the old item. If the old request subsequently fails, this equality succeeds and deletes the newer fingerprint, allowing a third identical event through. Fresh evidence beyond the prior report is that this collision occurs through same-tick clear()/reuse without waiting for the original 60-second window to expire; use a unique generation token rather than the timestamp itself.

Useful? React with 👍 / 👎.

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.

Valid, fixed in 5ffd6ca. The token is now a per-acceptance counter (acceptanceSeq), not the expiry, so a same-tick clear() and re-accept cannot collide with the in-flight item. Pinning test: "does not release a same-instant re-acceptance after clear() when the old send fails".

yosriady added a commit that referenced this pull request Aug 27, 2026
Review follow-up on #375. clear() and a re-accept of the same event can
land in the same millisecond as the original acceptance, so an expiry is
not a unique token: an old in-flight send failing afterwards could release
the newer entry. Each acceptance now carries a counter, and a failed or
abandoned item releases its entry only on a token match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
yosriady and others added 8 commits August 27, 2026 18:06
…ming

The dedup hash left the queue with its event at flush. Since the first
event of a page load is flushed immediately (#326), an identical track()
a moment later, the double-fire the guard exists for, was accepted.

Hashes now live in a Map with a 60s expiry independent of flush timing,
pruned on enqueue so the map is bounded by one minute of accepted events.
60s matches the hash itself, which truncates the timestamp to the minute.
The build target is ES5, so the prune uses Map.forEach.

Fixes #372.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
…the front

Review follow-ups on #375.

The dedup key was the message id, which folds in the minute-truncated
timestamp because it is the event's identity on the wire. A double-fire
straddling a UTC minute boundary (:59.999 and :00.001) therefore got two
ids and both went out. Duplicates are now judged by a sha256 of the event
without its timestamp; message_id is unchanged.

Pruning walked the whole map on every enqueue. Entries are in insertion
order and each expires a fixed interval after insertion, so the prune now
stops at the first live entry: each expired entry is visited once in its
life. Manual iterator because for..of over a Map does not compile under
the ES5 target and Map.forEach cannot stop early.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
…notonically

Review follow-ups on #375.

enqueue() re-checked close() after its hash awaits but not consent, so a
withdrawal in that gap could still buffer the event (the flush gate would
then drop it, but the contract of this path is to never buffer after
withdrawal). The consent gate is now repeated after the awaits.

The dedup window used Date.now(). A wall-clock step could expire entries
early or, stepping backwards, file a new entry behind older ones with later
expiries, out of reach of the front-prune. The window now uses
performance.now() where available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
Where performance.now() is unavailable the window fell back to Date.now(),
and a backward wall-clock step could file a new entry behind older ones
with later expiries, out of reach of the front-prune. The clock is now
clamped per instance to its last value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
…the dedup window

Review follow-ups on #375.

Fingerprints now outlive the flush, so they also outlived a send whose
retries were exhausted or whose response was not retryable. An app that
sent the same event again after the error callback had that retry dropped
as a double-fire. A failed batch now releases its fingerprints; a delivered
one keeps them.

performance.now() stops during OS suspension on some platforms, so a
device that slept mid-window woke still inside it. The window clock now
takes the larger of monotonic elapsed and wall elapsed, clamped to never
decrease: forward wall steps and suspension expire entries on time or
early, backward steps are absorbed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
…ndoned on opt-out

Review follow-ups on #375.

A failed batch released its fingerprints by key alone. A send can outlive
the window through retry backoff; by then the same event may have expired,
been accepted again and be in flight under the same key, and the old
failure would release that newer entry. Each item now records the expiry
it was accepted with and releases only on a match.

Batches abandoned when consent is withdrawn mid-flush were never sent but
kept their fingerprints, so an app retry inside the window after opt-in
was dropped. They are released before the break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
Review follow-up on #375. clear() and a re-accept of the same event can
land in the same millisecond as the original acceptance, so an expiry is
not a unique token: an old in-flight send failing afterwards could release
the newer entry. Each acceptance now carries a counter, and a failed or
abandoned item releases its entry only on a token match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
The dedup window (rolling expiry, timestamp-free fingerprint, front-only
prune, token-checked release, step-proof clock) adds about 0.5 kB brotlied.
CI measured 59.07 kB against 58.5 kB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69db1940de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/queue/EventQueue.ts Outdated
Comment on lines +582 to +584
const raw = Math.max(wall, monotonic);
if (raw > this.lastElapsed) this.lastElapsed = raw;
return this.lastElapsed;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rebase after transient forward wall-clock jumps

If elapsedNow() observes a forward wall-clock jump and the clock is subsequently corrected backward, this high-water clamp remains pinned to the jumped value until performance.now() or wall time catches up. Events accepted after the correction therefore receive expiries relative to the pinned value, so an identical event can remain suppressed for the duration of the jump (for example, nearly an hour) rather than 60 seconds. Fresh evidence beyond the prior backward-clock report is this forward-then-corrected sequence; rebase discontinuous wall-clock observations instead of permanently incorporating the transient maximum.

Useful? React with 👍 / 👎.

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.

Valid, fixed in a81c122. The wall source is now a sum of forward deltas rather than Date.now() - start under a high-water clamp: a forward step is added (expires early, the safe direction), a backward step adds nothing, and the sum resumes at real pace from the new reading either way. So a forward jump later corrected leaves the clock ahead but advancing, and an event accepted after the correction expires 60s of real time later. Both sources stay non-decreasing, so their max does and the prune's ordering assumption holds. Pinning test: "keeps real pace after a forward wall-clock jump that is later corrected".

@yosriady
yosriady force-pushed the fix/dedup-window-signed-wallet-snapshot branch from 69db194 to 227d9d4 Compare August 27, 2026 11:09
… pin the dedup window

Review follow-up on #375. A forward wall-clock step later corrected
backwards left the high-water clamp pinned at the stepped value until
real time caught up, so events accepted after the correction stayed
suppressed for the length of the step. The wall source is now a sum of
forward deltas: a step forward is added (expires early, the safe way), a
step back adds nothing, and the sum resumes at real pace either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a81c1228f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/queue/EventQueue.ts
Comment on lines +582 to +585
const wallNow = Date.now();
const delta = wallNow - this.lastWall;
this.lastWall = wallNow;
if (delta > 0) this.wallElapsed += delta;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Account for suspension after an unobserved backward clock step

On platforms where performance.now() pauses during OS suspension, a backward wall-clock adjustment made after the last enqueue but before sleep is never observed as its own delta. If the device wakes while Date.now() is still behind lastWall, this code discards the entire negative delta—including the real time spent asleep—while the monotonic source also shows no progress, so an event repeated after hours away can still be dropped as a duplicate. Fresh evidence beyond the earlier clock findings is this combined unobserved-backstep-then-suspension sequence; the elapsed-time calculation needs to preserve suspension time even when the post-wake wall reading has not caught up to the pre-adjustment value.

Useful? React with 👍 / 👎.

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.

Declined, with the bound. The sequence needs a backward wall step larger than the sleep, made after the last enqueue and never observed before suspension, on a platform whose performance.now() pauses in sleep. In that case neither source can see the time asleep: the wall clock reads behind its last sample and the monotonic clock did not run. No clock the browser exposes observes it either (there is no suspend-aware monotonic source in the web platform). The cost is bounded by the window itself: the entry expires after at most 60s of time the clock does observe after wake, which is the same worst case the guard had before this PR for any event. Given the preconditions and the bound, adding a third heuristic here is not worth its surface.

yosriady and others added 4 commits August 27, 2026 19:18
Review follow-up on #375. clear() wiped the whole fingerprint map, which
under the rolling window also held entries for delivered and in-flight
events. An opt-out while a batch was in flight, followed by an opt-in
inside the window, then accepted a copy of an event that had reached the
wire. clear() now releases only the buffered items it drops; close() is
terminal and still forgets everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
… is back

Review follow-up on #375. The post-await consent check sampled the
predicate, so an opt-out and opt-in inside the hash awaits let the
pre-withdrawal event through. clear() now bumps a generation counter that
enqueue() captures before its awaits and compares after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
…e; flush re-checks after waiting

Review follow-ups on #375. sendBatches sampled consent between split
batches, so an opt-out and opt-in while one batch was in flight let the
pre-withdrawal batches behind it go out; they had been spliced from the
queue, out of clear()'s reach. The flush now captures the clear generation
and abandons (and releases) the remaining batches when it changes.

flush() also did not re-check state after waiting on a pending flush, so
a clear() or close() in that wait left it splicing nothing and POSTing an
empty batch. The consent and empty-queue gates are repeated after the wait.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
Review follow-up on #375. The corrected-jump test set the clock forward
and back with no read in between, so the forward-delta branch went
unexercised.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
… drainAll with a pending flush

Coverage review on #375: these were the only branches new to this PR
without a test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YEjK9qkwf3u1CpGkb5NvYG
@yosriady

Copy link
Copy Markdown
Contributor Author

Closing for now. The exposure is narrow (only the first event of a page load, double-fired within a minute, before the page hit lands) and the complete fix grew a fair amount of clock and consent machinery to be airtight. The branch stays as a reference: CI green, 22 pinning tests, ten Codex passes with no majors remaining, e2e green against the examples. Reopen if duplicate custom-event counts show up in practice.

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.

Immediate first-event flush drops its dedup hash, weakening duplicate suppression

1 participant