Skip to content

[web-shared] Mark ignored duplicate events in the observability UI - #3467

Merged
VaguelySerious merged 8 commits into
mainfrom
peter/duplicate-events-ui
Aug 13, 2026
Merged

[web-shared] Mark ignored duplicate events in the observability UI#3467
VaguelySerious merged 8 commits into
mainfrom
peter/duplicate-events-ui

Conversation

@VaguelySerious

@VaguelySerious VaguelySerious commented Aug 11, 2026

Copy link
Copy Markdown
Member

#3381 makes the runtime pass over an event that repeats a class the log already records for the same entity. Nothing about that reaches the UI: events are immutable and carry no "ignored" marker, so the observability UI showed such an event as ordinary progress and let it move derived state.

image

What this does

Adds findDuplicateEventIds(events, { isCompleteHistory }) to @workflow/web-shared, which derives the passed-over set from the event list using entityEventClass from @workflow/world (added in #3381).

The rule follows consumer lifetime rather than event type. The runtime steps over an event only when its class was already recorded for the entity and no registered callback claims it, and a callback stays registered for as long as the entity is open. So a repeat counts here only once a terminal event for the same entity (step_terminal, wait_completed, hook_disposed) sits earlier in the log, which is the point past which no consumer remains. run_started is the one class with no entity to close first: workflow.ts declines a second one outright.

That means a retried step's repeated step_started / step_retrying, a second step_created on a step still in flight, and repeated hook_received deliveries are all left alone. A step_started after the step's outcome is marked.

The fold walks in log order, sorting on eventId (fixed-width and monotonic within a run under both id schemes). Timestamps are not authoritative: a writer stamps createdAt on entry but takes its log position at publish time, and occurredAt is measured on the client.

Two surfaces show it:

  • Sidebar event list item and the main event list view: the event name renders greyed out (gray-700) with a tooltip.

Three derived views stop counting them:

  • materializeSteps keeps the outcome the run acted on. Before, a step_completed written by a losing replay after a step_failed flipped the materialized status.
  • buildDurationMap measures "Ran for" against the terminal event the run acted on, not a later repeat.
  • buildTrace filters them before grouping, so a span bar ends where the step ended rather than where the losing replay committed. The event lists still receive the full log, so a marked event is still listed under its entity.

Incomplete histories

Which occurrence of a class came first is a property of the whole log. On a page of a paginated list or the result of a search, the earlier event can be missing, and the fold would report the surviving one instead. So findDuplicateEventIds requires the caller to vouch for completeness and classifies nothing otherwise: EventListView passes !hasMoreEvents && !isExactSearchActive, TraceViewer passes !hasMore. The derived-state helpers take the same flag and default it to false, so an unset caller behaves as it did before this PR. The sidebar only ever sees one entity's slice, so it takes the answer as data from the caller that holds the whole log.

Wording

The tooltip says what the log shows rather than what the runtime did with it: "Written by a concurrent replay after an event of the same kind was already recorded and acted on. The run follows the earlier one." Tolerating these repeats is recent, and on a run recorded before #3381 an unclaimed repeat failed the replay rather than being passed over.

Tests

packages/world/src/test-support/duplicate-event-fixtures.ts holds nine logs and the indices no consumer claims. Both sides run them: packages/core/src/duplicate-event-fixtures.test.ts drives them through EventsConsumer with consumers modeling the lifetimes in step.ts / sleep.ts / workflow.ts, and duplicate-events.test.ts runs the same fixtures through the UI classifier. A fixture whose expectation moves fails on both sides.

Beyond the fixtures, duplicate-events.test.ts, event-materialization.test.ts, and trace-builder.test.ts cover log order conflicting with createdAt and with occurredAt, tied timestamps in both caller sort directions, the incomplete-history path, the preserved step outcome, the attempt count on a retried step, and the span end time.

No change was needed in packages/web: it renders EventListView from web-shared, and its flow-graph mapper already takes the first event per correlation id.

Concurrent replays of one run share an event log, so a replay working from
a stale prefix can commit a write the log already records. The runtime
passes over those. The UI showed them as ordinary progress and let them
move derived state.

Derive the set of passed-over events from the log with `entityEventClass`,
restricted to the classes a run records at most once per entity. A retried
step legitimately repeats `step_started` and `step_retrying`, one per
attempt, so those two are excluded and never marked.

Marked events read greyed out in the sidebar event list and the events
table, with a tooltip saying the event was ignored. They are also kept out
of the derived step status, the queued/ran durations, and trace span
geometry, where a second terminal event would otherwise stretch a step to
whenever the losing replay happened to commit.
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 13, 2026 6:20pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 13, 2026 6:20pm
example-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-astro-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-express-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-fastify-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-hono-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-nestjs-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-nitro-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-nuxt-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-python-workflow Error Error Aug 13, 2026 6:20pm
workbench-sveltekit-workflow Building Building Preview Aug 13, 2026 6:20pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workbench-vite-workflow Ready Ready Preview Aug 13, 2026 6:20pm
workflow-docs Ready Ready Preview, v0 Aug 13, 2026 6:20pm
workflow-swc-playground Ready Ready Preview Aug 13, 2026 6:20pm
workflow-tarballs Ready Ready Preview Aug 13, 2026 6:20pm
workflow-web Ready Ready Preview Aug 13, 2026 6:20pm

@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e7c690f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@workflow/web-shared Patch
@workflow/web Patch
@workflow/cli Patch
@workflow/nitro Patch
workflow Patch
@workflow/world-testing Patch
@workflow/nuxt Patch
@workflow/core Patch
@workflow/builders Patch
@workflow/next Patch
@workflow/vitest Patch
@workflow/astro Patch
@workflow/nest Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
@workflow/vite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 Mint-ordered log — 6 fail of 41 total

log=mint-ordered · fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 17 1.0m MISMATCH 1
stale-read-equal-step-counts completed 14 1.0m MISMATCH 1
step-vs-step-fork completed 12 0ms MISMATCH 1
step-vs-step-fork-fenced completed 12 0ms MISMATCH 1
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m MISMATCH 1
in-flight-before-decision-counted completed 20 1.0m ok 0
in-flight-after-decision failed 14 2.0m MISMATCH 1
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim-mint.txt

🟢 Append-only log — 0 fail of 41 total

log=append-only · fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m ok 0
in-flight-before-decision-counted completed 17 1.0m ok 0
in-flight-after-decision completed 19 2.0m ok 0
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim-append-only.txt

An event the caller cannot identify cannot be marked: callers match on the
id, so reporting a missing one tarred every other id-less event with it.
@VaguelySerious

Copy link
Copy Markdown
Member Author

(AI) CI state on this branch, for anyone reading the red:

Nothing in this PR touches those paths: the diff is packages/web-shared plus a shared test fixture in packages/world/src/test-support.

* first so a shorter ID never sorts after a longer one on a fixture or a log
* that mixes widths.
*/
function compareLogPosition(a: Event, b: Event): number {

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.

AI Review: eventId is not the authoritative order for every supported history. world-local uses the ID as a sort key only for slot-numbered runs; for legacy ULID runs, eventSortKey() returns null and events.list() deliberately orders by (createdAt, eventId). Since createdAt is captured at create() entry and the ULID is minted later, concurrent writers can produce opposite timestamp and ID orders. The runtime then consumes the backend timestamp-ordered log while this UI fold can reverse the canonical and duplicate events. Please pass an authoritative backend log-position/order key into this classifier (or otherwise preserve a guaranteed authoritative ordering contract) rather than assuming every event ID is a log position, and add a legacy-ULID test where createdAt and eventId disagree.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(AI) Verified and fixed in 94e05bd.

You are right, and the premise checks out in the backend code: eventSortKey returns the ID only for a slot-numbered run and null otherwise, and paginatedFileSystemQuery then sorts a ULID run by (createdAt, eventId). world-vercel does no client-side sort, so it hands back the store-key order. So for a ULID log the two supported backends can genuinely disagree, and createdAt is stamped when the write arrives rather than when it commits.

I could not pass an authoritative position key in, because none of the reads that feed this UI carry one: for a ULID run the position exists only as the order the backend chose to return, and the UI receives the array already sorted by its own caller (both findDuplicateEventIds tests for caller-order independence exist because of that). So instead of assuming a contract, the classifier now establishes when it has one:

  • All slot IDs: the slot is drawn at the publish, which is the linearization point, so slot order is log order in both backends and the ID settles it.
  • Otherwise: the ID order must be corroborated by non-decreasing createdAt. Where the two contradict, which occurrence the run acted on depends on which backend served it, so nothing is classified.

Added classifies a ULID log whose timestamps corroborate its ids and classifies nothing on a ULID log whose timestamps contradict its ids, both on ULID-shaped IDs (the existing helper mints slot IDs, which isSlotEventId classifies as slots, so those tests were only ever covering the slot rule).

closedEntities.add(entity);
}

if (!repeatsClass) {

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.

AI Review: The fold records a previously unseen class even when it first appears after the entity has already closed. For [step_created, step_completed, step_started, step_started], the runtime stops with replay divergence on the first trailing step_started; it never reaches or classifies the second one. This fold leaves the first unmarked, adds step_started to seenClasses, then falsely marks the second as an ignored duplicate. Once a closed entity encounters an unseen class, classification for that entity should become indeterminate (or the fold should stop), and that class should not be added to seenClasses. Please add this four-event sequence to the shared runtime/UI fixtures.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(AI) Correct, fixed in 94e05bd.

Walking your sequence against the fold confirmed it: the first trailing step_started took the !repeatsClass branch and was recorded, which made the second one look like a settled repeat although the runtime had already parked on the first and exited.

I took the "stop the fold" option rather than per-entity indeterminacy, because divergence ends the run rather than that one entity: events after it were never read either, so marking a sibling's repeat past that point would have the same defect one entity over. So on a closed entity meeting an unseen class the fold breaks, and the class is never recorded.

Added the four-event sequence to the shared fixtures as repeat of a class the log has not recorded yet with ignoredIndices: []. It passes on both sides: the runtime half stops on index 2 with an unconsumed event and never calls onDuplicateEvent. The UI test classifies nothing past the point the run diverged also pins the other half of the rule, that a repeat before the divergence point is still classified.

Two gaps in the fold, both from review on #3467.

The event ID is a log position only for a slot-numbered run, whose slot is
drawn at the publish. A ULID-numbered run is served by one backend in
(createdAt, eventId) order and by another keyed on the ID, so concurrent
writers can produce opposite orders and the ID alone does not fix the
position. Such a log is now classified only where its timestamps corroborate
its IDs.

The fold also recorded a class first seen after its entity had closed, which
is the point the runtime reports divergence and exits. A later event of that
class then looked like a settled repeat although the run never read it. The
fold stops there instead.

@karthikscale3 karthikscale3 left a comment

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.

Re-reviewed at e7c690f. The latest changes address the event-order ambiguity and post-divergence classification findings with conservative fallbacks and regression coverage. No remaining code-review blockers from me.

@VaguelySerious

Copy link
Copy Markdown
Member Author

Note that https://workflow-web-git-peter-duplicate-events-ui.labs.vercel.dev/?period=1h&status=completed currently fails to load due to scan limits, so couldn't re-test, but will follow up

@github-actions

Copy link
Copy Markdown
Contributor

No backport to stable for 834d1f9 (AI decision).

This is a feature enhancement to the observability UI: it adds a new exported API (findDuplicateEventIds, DUPLICATE_EVENT_MESSAGE), a new tooltip component, and new greyed-out rendering for duplicate events. It also depends on entityEventClass and isSlotEventId from @workflow/world, which were introduced on main in #3381 and do not exist anywhere on stable (verified via git grep against origin/stable), so the runtime behavior it visualizes isn't even present there. The embedded correctness fixes (materializeSteps keeping the acted-on outcome, buildDurationMap/buildTrace excluding repeats) are only reachable through the new main-only classifier, so they can't be split out cleanly for stable.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

834d1f945ff2e2f72db5b589315bab92c4c821d3

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.

2 participants