Skip to content

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

Draft
VaguelySerious wants to merge 6 commits into
mainfrom
peter/duplicate-events-ui
Draft

[web-shared] Mark ignored duplicate events in the observability UI#3467
VaguelySerious wants to merge 6 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 12, 2026 9:43pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 12, 2026 9:43pm
example-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-astro-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-express-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-fastify-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-hono-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-nestjs-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-nitro-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-nuxt-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-python-workflow Error Error Aug 12, 2026 9:43pm
workbench-sveltekit-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workbench-vite-workflow Ready Ready Preview Aug 12, 2026 9:43pm
workflow-docs Ready Ready Preview, v0 Aug 12, 2026 9:43pm
workflow-swc-playground Ready Ready Preview Aug 12, 2026 9:43pm
workflow-tarballs Ready Ready Preview Aug 12, 2026 9:43pm
workflow-web Ready Ready Preview Aug 12, 2026 9:43pm

@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8955cce

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.

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.

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