Skip to content

Commit 066a662

Browse files
tegan-temporalGiantRobotscursoragent
authored
feat(timeline): HTML virtualization + collapse idle time (#3627)
* perf(timeline): grouped-event-buffer, window virtualization, sticky canvas Introduce a module-level grouped-event-buffer singleton that owns event state via callbacks rather than Svelte reactive stores, eliminating memory pressure when handling 50k+ events. Implement window-based SVG virtualization with a sentinel-scroll pattern: only ~101 rows stay in the DOM (OVERSCAN=20) with hysteresis preventing window thrashing on every pixel of scroll. CSS transform + will-change on the SVG element lets the compositor pan a cached layer between updates. Supporting changes: - Extract timeline-positioning.ts for row Y calculation logic - Cache getGroupArray() to eliminate O(N log N) sort on each call - Short-circuit expensive onMount scan in TimelineGraphRow for non-local-activity - Remove incremental windowing (applyWindowStep) that invalidated the GPU compositor layer every frame - Add perf-events / perf-signals scripts for local load testing * perf(timeline): virtualization simplification, WFT filter, sorted eventList Virtualization - Replace hysteresis $effect + $state window tracking with a single $derived; scrollY is already rAF-throttled so per-frame overhead is just O(1) arithmetic in getWindowBounds, not DOM reconciliation - Reduce OVERSCAN 20 → 8 rows (480 → 192 px buffer per side); rows are now icon + text only so a smaller buffer is sufficient Timeline layout - Filter WorkflowTask groups from the timeline via getGroupArray({ excludeWorkflowTasks: true }) at all call sites; WFTs remain visible on the workflow events tab - Add a second getGroupArray cache (_cachedGroupsNoWFT) so the WFT-excluded path is O(1) after first build, same as the full cache - Fix spacerHeight to account for stickyHeight and panelHeight so the scroll range correctly covers the full content height - Expose panelHeight as $bindable() from TimelineGraph so the layout can incorporate open detail panel height into the spacer calculation Event ordering - Replace eventList.push() with insertEventById (insertion sort by numeric event ID) in attachFollowerToPool and appendLiveEvent; the descending cursor delivers higher IDs first, causing detail panels and grouping predicates to see events out of order without this fix - Add unit test covering the desc-cursor out-of-order arrival case Rendering - Replace SVG dashed tick lines with a CSS repeating-linear-gradient background; recomputes only on resize, never during scroll - Replace foreignObject skeleton placeholder with an SVG <rect> to avoid reflows during loading - Clip border lines to the virtualized window bounds * perf(timeline): reduce reactive signal count and stabilize scroll height Signal reduction - timeline-graph-row: convert accessibleName to a plain const (translate called once at mount, not on every reactive flush); move hover-width calculations into {#if hovering} with {@const} so they only run while hovered; keep pendingActivity/pauseTime/pendingLine as $derived since enrichGroups mutates the group in place after fetch completes - dot: consolidate two $derived color signals into one (colorPair); use point[0]/point[1] directly in template, removing the [x,y] destructure signal - line: replace $derived strokeColor with a plain computeStrokeColor() function called in the template; replace [x1,y1]/[x2,y2] destructure signals with direct array access — template expressions are reactive without needing script-level $derived - text: remove [x,y] $derived destructure, use point[0]/point[1] directly Net: ~17 fewer reactive signals per mounted row. At 50 visible rows that is ~850 fewer Signal objects created/tracked by Svelte's reactive graph, and ~850 fewer to GC as the virtualization window scrolls. Scroll height stability - pendingGroupCount now uses totalExpectedEvents directly as the row estimate instead of recalculating density on every streaming batch; totalExpectedEvents is a server constant so pendingGroupCount decrements by exactly 1 per new group — totalForY stays flat and no existing rows shift position as pages arrive - spacerHeight (the page-level scroll range) uses totalExpectedEvents while loading so the scrollbar thumb size and position are stable from the first paint; switches to actual group count once fetchComplete * Get rid of some warnings * fix(timeline): restore A/B parity with master — auto-refresh, canvas height, WFT filter Functional fixes - Auto Refresh toggle now wires up correctly: imports pauseLiveUpdates, syncs refresh_off URL param on load, calls onAutoRefreshToggle on click, and reflects paused state in the dot colour / button label - Compact (event history) view no longer leaks WorkflowTask groups — all getGroupArray() call sites in workflow-history-layout now pass { excludeWorkflowTasks: true } - Fix import ordering in events.ts and scripts/start-long-running.ts (pnpm lint 0 errors) Visual fixes - Remove -mx-4 / md:-mx-8 negative margins from the sticky canvas wrapper so the inner timeline-graph border is visible, matching master's look - Introduce canvasContentHeight = max((rows+2)*ROW_PX,120) + panel + 120 and use min(canvasContentHeight, 100dvh-nav) as the sticky height; small histories render a compact canvas while large ones still use the full-viewport virtual-scroll path New tests - event-filter-params.test.ts: parseEventFilterParams / updateEventFilterParams round-trip refresh_off correctly - events.test.ts (locale): expand-details + collapse-details keys exist - grouped-event-buffer.test.ts: compact-view regression — getGroupArray with excludeWorkflowTasks never returns WFT groups even when interleaved Co-authored-by: Cursor <cursoragent@cursor.com> * feat: event row expand chevrons, zoom-svg controls, long-running test workflows UI regressions restored - event-summary-row: add expand/collapse chevron (IconButton) for inline event detail expansion; detailsId ties aria-controls to the detail row; onLinkClick made optional so programmatic calls don't throw - zoom-svg: implement panBy / zoomBy helpers, keyboard handler (arrows to pan, +/- to zoom), and a bottom-right button cluster (pan ↑↓←→, zoom in/out, fit/reset) matching the master branch Relationships tab controls Data / store fixes - grouped-event-buffer: WorkflowExecutionStarted solo events now included in getEventArray() output so child workflow input fields are populated - workflow-details: migrate totalActions derivation from fullEventHistory store to getEventArray() + bufferVersion signal, eliminating the stale second copy of event objects Test infrastructure - Add longSleep + alwaysFails activities (long-sleep.ts) for realistic pending-activity and retrying-activity test scenarios - Add PendingActivityWorkflow, PendingTimerWorkflow, RetryingActivityWorkflow, PendingChildWorkflow, MixedOpenWorkflow, SignalUpdateWaitWorkflow, LongSleepChildWorkflow, ConcurrentPendingWorkflow to temporal/workflows.ts - scripts/start-long-running.ts: runner that starts all long-running workflows against a local Temporal server; registered as pnpm run-workflows:long-running in package.json - workflow-history-json: thread events prop through so JSON view reflects the current filtered set Co-authored-by: Cursor <cursoragent@cursor.com> * fix(timeline): eliminate controls-canvas gap and restore top border Three layout bugs fixed: 1. 32px phantom gap — the parent flex container uses gap-4 (16px). The sentinel (h-0) was a standalone flex child, contributing two gaps (one before, one after) = 32 px of empty space between the controls bar and the sticky canvas. Fix: wrap controls + sentinel + canvas + spacer in a single block-flow div so the parent gap only fires once (above the whole block) and the children stack with zero internal gaps. 2. Missing top border — timeline-graph.svelte uses border-t-0 (designed to share the controls bar's border-b in master's inline layout). The sticky canvas wrapper now carries border-t border-subtle, completing the four-sided border rectangle. 3. Controls bar overlap when scrolled — both the controls bar and the sticky canvas were sticky at top: var(--top-nav-height), so on scroll the controls bar covered the top ~53 px of the canvas. The canvas sticky top is now calc(var(--top-nav-height) + {controlsHeight}px), measured live via bind:clientHeight, so the canvas always sits flush below the controls bar. The CSS height cap is updated to subtract controlsHeight from 100dvh so the canvas never overflows the viewport. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: timeline architecture and virtualization reference Co-authored-by: Cursor <cursoragent@cursor.com> * docs: expand reactivity section — buffer signals vs Svelte primitives Co-authored-by: Cursor <cursoragent@cursor.com> * docs(timeline): add group assembly section to architecture doc Documents the park-and-flush pattern for split groups: the four follower resolution paths, what pendingFollowers vs livePendingFollowers each store, the dedup guard in attachFollowerToPool, and how live groups transition out of getGroupArray() once the pool claims the head. Also expands the new-tests table to cover all test files added during this work. * Drop some old architecture stuffs * Update arch doc * Fix pending issues * refactor(event-groups): eventList-based group model * feat(services): streaming event buffer, bidirectional fetch, live poll * feat(stores): event-view/actions-ready stores and history context * feat(utilities): time formatting, timeline sorting, type predicates * feat(timeline): HTML timeline-graph rendering layer * feat(timeline): history-graph and shared lines-and-dots pieces * refactor(timeline): remove the old SVG timeline * refactor(components): shared holocene tweaks and composited heartbeat * refactor(events): event/payload/activity component updates * feat(timeline): wire the timeline into layouts and pages * chore(i18n): timeline strings * chore(timeline): perf/test harness, e2e specs, docs, tooling * Dedupe change * use transform * perf(timeline): recycle row-pool slots to cut scroll recomputes Map group i to a fixed slot (i % poolSize) and reuse the prior slot object when unchanged, so a scroll re-points only the slots that actually change instead of shifting every slot's group each frame. The stable object identity keeps the {#each} item unchanged so a row's point-computation derived no longer re-runs for rows that didn't move. --------- Co-authored-by: alex thomsen <alex.scott.thomsen@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent af5c940 commit 066a662

95 files changed

Lines changed: 10360 additions & 2086 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.stylelintrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
}
3838
],
3939
"custom-property-pattern": null,
40+
"custom-property-empty-line-before": null,
4041
"declaration-block-no-redundant-longhand-properties": [
4142
true,
4243
{

docs/timeline-architecture.md

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# Timeline & Event History Architecture
2+
3+
## Core Principle: One Copy, One Truth
4+
5+
All event data lives in a **single module-level singleton** (`grouped-event-buffer.ts`).
6+
Consumers read it via `getEventArray()` / `getGroupArray()`. A thin `bufferVersion`
7+
signal and `fullEventHistory` store expose it to Svelte, but they carry references
8+
to the same objects — never copies.
9+
10+
```mermaid
11+
flowchart LR
12+
API["API / live stream"] -->|processEvent| BUF
13+
14+
subgraph BUF["grouped-event-buffer.ts (singleton)"]
15+
direction TB
16+
EV["events[]<br/>one array, no copies"]
17+
GR["groups[]<br/>metadata only — stores IDs + refs"]
18+
SO["soloEvents[]<br/>WorkflowExecutionStarted etc."]
19+
ID["liveSeenIds Set<br/>dedup guard"]
20+
end
21+
22+
BUF -->|getEventArray| TL["Timeline layout"]
23+
BUF -->|getGroupArray| HL["History layout"]
24+
BUF -->|getEventArray| WD["Workflow Details"]
25+
26+
BUF -->|bufferVersion signal| TL
27+
BUF -->|onLatestGroup callback| HL
28+
```
29+
30+
### Key tenets
31+
32+
| Tenet | How |
33+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
34+
| **No second data structure** | `EventGroup` is a metadata envelope — it stores event IDs and a reference to the head event; it does not copy field values |
35+
| **No duplicate objects** | `liveSeenIds` rejects any event ID seen before; `appendLiveEvent` and `processEvent` both gate on this set |
36+
| **Lazy detail rendering** | Raw event payload is read from the existing array entry only when a detail panel opens — not on every group build |
37+
| **Solo events included** | `WorkflowExecutionStarted` events that don't belong to a group live in `soloEvents[]` and are merged into `getEventArray()` at read time |
38+
| **Reactive signal is cheap** | `bufferVersion` is a plain Svelte `writable(0)` incremented on each buffer flush; consumers `$derived` off it to re-read without diffing the full array |
39+
40+
---
41+
42+
## Group Assembly: Park-and-Flush Pattern
43+
44+
Temporal events arrive out of order. A group (Activity, Timer, Child Workflow, …) is made up of 2–5 events where only the **head event** (e.g. `ActivityTaskScheduled`) carries the group identity. Followers (Started, Completed) reference the head by ID.
45+
46+
Two separate sources can deliver events concurrently:
47+
48+
- **Bidirectional fetch** — ascending cursor from event 1, descending cursor from the last event, racing toward the middle.
49+
- **Live poll** — long-poll at the frontier while a workflow is running.
50+
51+
Both use the same core pattern: **park followers invisibly until the head arrives, then flush them into the group in one shot.**
52+
53+
```mermaid
54+
flowchart TD
55+
subgraph SOURCES["Event sources"]
56+
ASC["Ascending cursor\n(processEvent)"]
57+
DESC["Descending cursor\n(processEvent)"]
58+
LIVE["Live poll\n(appendLiveEvent)"]
59+
end
60+
61+
ASC & DESC --> PE["processEvent(event)"]
62+
LIVE --> ALE["appendLiveEvent(event)"]
63+
64+
PE -->|isHead = true| POOL["groupPool slot\neventToGroup[slot] = poolIdx+1"]
65+
PE -->|isHead = false| ATF["attachFollower(headSlotIdx, followerSlotIdx)"]
66+
67+
ATF -->|head already in pool| ATFP["attachFollowerToPool\n→ insertEventById into group\n→ eventSlots[slot] = null"]
68+
ATF -->|head not yet in pool| PF["pendingFollowers.set(headSlot, followerSlots[])"]
69+
70+
POOL -->|flush| PF_FLUSH["flush pendingFollowers\n→ attachFollowerToPool × N"]
71+
POOL -->|flush| LPF_FLUSH["flush livePendingFollowers\n→ insertEventById × N\n→ eventToGroup[slot] = poolIdx+1"]
72+
73+
ALE -->|isHead = true| LG["liveGroups entry\n+ flush livePendingFollowers"]
74+
ALE -->|head in liveGroups| LG_EXT["extend live group directly"]
75+
ALE -->|head in groupPool| POOL_EXT["extend pool group directly\n(Option B)"]
76+
ALE -->|head unknown| LPF["livePendingFollowers.set(headId, events[])"]
77+
```
78+
79+
### Four resolution paths for a follower event
80+
81+
| Source | Head already where? | Action |
82+
| ------------------------ | ------------------- | --------------------------------------------------------------------------------------- |
83+
| `processEvent` (bidir) | groupPool | `attachFollowerToPool` — inserts immediately, nulls raw slot |
84+
| `processEvent` (bidir) | Nowhere yet | `pendingFollowers` map — parks slot index; flushed when head arrives |
85+
| `appendLiveEvent` (live) | liveGroups | Extends live group's `eventList` in place |
86+
| `appendLiveEvent` (live) | groupPool | Extends pool group's `eventList` directly; marks `eventToGroup[followerSlot]` |
87+
| `appendLiveEvent` (live) | Nowhere yet | `livePendingFollowers` map — parks converted `WorkflowEvent`; flushed when head arrives |
88+
89+
**A group only becomes visible in `getGroupArray()` once its head has been processed.** No partial or stub groups are rendered.
90+
91+
### What each map stores
92+
93+
```
94+
pendingFollowers: Map<headSlotIdx: number, followerSlotIdx[]: number[]>
95+
↑ slot indices only — raw HistoryEvent stays in eventSlots[]
96+
97+
livePendingFollowers: Map<headEventId: string, WorkflowEvent[]>
98+
↑ already-converted events — appendLiveEvent never writes to eventSlots
99+
```
100+
101+
One copy of each event exists at any time — either waiting in a park map, or committed inside `group.eventList`.
102+
103+
### Dedup when both sources deliver the same event
104+
105+
`attachFollowerToPool` guards with:
106+
107+
```
108+
if (eventToGroup[followerSlotIdx] !== 0) { null the slot; return; }
109+
```
110+
111+
If `livePendingFollowers` already claimed a slot (by writing `eventToGroup[followerSlotIdx]` during flush), the bidirectional cursor's delivery of the same event is silently discarded.
112+
113+
### Live groups in `getGroupArray()`
114+
115+
Complete live groups (head delivered by live poll) are included alongside pool groups. Once `processEvent` claims the head (`eventToGroup[headSlot] !== 0`) the live group is excluded — the pool group takes over with an identical or superset `eventList`.
116+
117+
---
118+
119+
## Reactivity: Events and Callbacks, Not Svelte Primitives
120+
121+
The buffer is **plain TypeScript** — no `$state`, no `$derived`, no stores inside the module.
122+
Svelte's reactive graph is only entered at the outermost boundary, via two narrow escape hatches.
123+
124+
```mermaid
125+
flowchart TD
126+
subgraph NEW["✅ New approach (buffer + narrow signals)"]
127+
direction LR
128+
N1["Plain TS array mutated<br/>in-place — no reactive cost"] -->|batch complete| N2["bufferVersion.set(v+1)<br/>one integer write"]
129+
N2 -->|$derived reads version| N3["Consumer calls getEventArray()<br/>reads already-built array — O(1)"]
130+
N1 -->|group head appended| N4["onLatestGroup() fires<br/>registered callback"]
131+
N4 --> N5["Layout calls getGroupArray()<br/>reads cached sorted slice — O(1)"]
132+
end
133+
```
134+
135+
```mermaid
136+
flowchart TD
137+
subgraph OLD["❌ Old approach (stores)"]
138+
direction LR
139+
O1["Svelte writable store<br/>holds full WorkflowEvent[]"] -->|every push triggers| O2["$derived chains re-run<br/>across all subscribers"]
140+
O2 --> O3["Full array diff + re-render<br/>at 50k events = jank"]
141+
end
142+
```
143+
144+
### Why this matters at scale
145+
146+
| | Svelte store approach | Buffer + signal approach |
147+
| ---------------- | ----------------------------------------------- | ------------------------------------------------------------------------- |
148+
| 10 k event push | Re-runs every `$derived` chain for each push | Mutates array silently; one `bufferVersion` tick at end |
149+
| Subscriber count | Every component watching the store re-evaluates | Only components that read `bufferVersion` or register via `onLatestGroup` |
150+
| Memory per event | Two copies — one in store, one in group | One copy in `events[]`; group holds a reference to the same object |
151+
| Render trigger | Svelte decides (potentially every frame) | Explicit: either a version bump or a callback — nothing else |
152+
153+
### The two signal types
154+
155+
**`bufferVersion`** — a plain `writable(0)`. Consumers write:
156+
157+
```svelte
158+
let rows = $derived.by(() => {
159+
$bufferVersion; // subscribe to the tick
160+
return getEventArray(); // read the already-built array
161+
});
162+
```
163+
164+
No array is passed through the signal. The signal carries only the intent to re-read.
165+
166+
**`onLatestGroup(cb)`** — a callback registration (pub/sub, not Svelte reactive). Layouts register on mount and receive a teardown function. The buffer calls every registered callback synchronously after appending a new group head. No Svelte primitive involved — the callback fires imperative code that then reassigns a `$state` variable once, queueing exactly one Svelte flush.
167+
168+
---
169+
170+
## Virtualization: Scroll-Driven Window + Row Pool
171+
172+
The timeline can have tens of thousands of rows; only a small pooled window is
173+
ever in the DOM. Rows are plain absolutely-positioned **HTML** divs (not SVG).
174+
175+
```mermaid
176+
flowchart TD
177+
A["scroll / wheel / touchmove"] --> B["pokeSampler: (re)start the rAF loop"]
178+
B --> C["rAF: read container offset via getBoundingClientRect\n→ visible pixel band"]
179+
C --> D{band changed?}
180+
D -- no, N frames --> E["idle out (loop stops)"]
181+
D -- yes --> F["getWindowBounds(band) → [start, end) row indices"]
182+
F --> G["pool re-points its slots to those groups\n(no create/destroy — props update in place)"]
183+
G --> C
184+
```
185+
186+
- **The container is the full drawn height** (`svgHeight`) and scrolls with the
187+
page inside its overflow ancestor (found via `findScrollParent`, in practice
188+
`#content-wrapper`). There is no `translateY`, no sentinel, and no spacer div —
189+
the page scrolls the tall container directly.
190+
- **Band measurement is scroll-driven, per frame.** A self-driven `requestAnimationFrame`
191+
loop reads the container's offset within its scroll parent each frame and idles
192+
out after a few still frames. It's kept alive by `scroll` **and** `wheel` /
193+
`touchmove`, because a wheel/trackpad fling fires `wheel` but coalesces `scroll`
194+
— an event-only measure goes stale mid-fling and rows blank out. (This replaced
195+
an earlier IntersectionObserver approach, which the browser throttles during
196+
fast scroll.)
197+
- **`getWindowBounds`** is the closed-form inverse of `getRowY` — it maps the
198+
visible band to a `[start, end)` row-index range (ascending/descending/pending-gap
199+
aware), plus `OVERSCAN` rows on each side.
200+
- **Row pool.** Instead of a keyed `{#each}` that creates/destroys rows as the
201+
window slides, a fixed-size set of slots (keyed by slot **index**) is reused. As
202+
you scroll, each slot re-points to a new group — the `<li>` and its component
203+
instance stay mounted and only props update. This removed the `cloneNode` /
204+
insert / effect-teardown churn that was tripping frequent major-GC pauses.
205+
206+
### Positioning math (`timeline-graph/timeline-positioning.ts`)
207+
208+
- `getRowY(i, …)` — y (px) for the group at index `i`; descending-cursor rows
209+
shift down by `pendingGroupCount` to open the loading gap.
210+
- `getPendingBlockY(…)` — top of the skeleton gap rectangle.
211+
- `getDescStart` / `getTotalForY` — locate the cursor split and the descending-sort
212+
denominator.
213+
- Row height and dot radius come from `timeline-graph/constants.ts`
214+
(`ROW_HEIGHT`, `RADIUS`, `GUTTER`); the color helpers live in `lines-and-dots/colors.ts`.
215+
216+
> Point-in-time change summaries (regressions fixed, tests added, test-workflow
217+
> setup) intentionally live in the PR description and git history, not here, so
218+
> this document can stay a description of the _current_ architecture.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
"serve:playwright:e2e": "vite build && vite preview --mode test.e2e",
3939
"serve:playwright:integration": "vite build && vite preview --mode test.integration --port 3333",
4040
"test": "TZ=UTC vitest",
41+
"perf:signals": "esno scripts/perf-signals.ts",
42+
"perf:events": "esno scripts/perf-events.ts",
4143
"test:ui": "TZ=UTC vitest --ui",
4244
"test:coverage": "TZ=UTC vitest run --coverage",
4345
"test:e2e": "PW_MODE=e2e playwright test tests/e2e",
@@ -53,6 +55,8 @@
5355
"prettier:fix": "prettier --write --plugin prettier-plugin-svelte --plugin prettier-plugin-tailwindcss .",
5456
"preview:local": "VITE_TEMPORAL_UI_BUILD_TARGET=local vite preview",
5557
"preview:docker": "VITE_API=http://localhost:8080 VITE_TEMPORAL_UI_BUILD_TARGET=local vite preview",
58+
"preview:local-temporal": "VITE_API=http://localhost:8081 vite build --sourcemap && VITE_TEMPORAL_UI_BUILD_TARGET=local vite preview --port 3001",
59+
"preview:local-temporal:full": "trap 'kill 0' EXIT INT TERM; pnpm temporal-server & pnpm dev:local-temporal & pnpm preview:local-temporal",
5660
"package": "svelte-package",
5761
"package:patch": "pnpm version patch && svelte-package",
5862
"package:minor": "pnpm version minor && svelte-package",
@@ -65,6 +69,7 @@
6569
"stylelint:fix": "stylelint --fix \"src/**/*.{css,postcss,svelte}\"",
6670
"generate:locales": "esno scripts/generate-locales.ts",
6771
"run-workflows": "esno scripts/run-workflows.ts",
72+
"run-workflows:long-running": "esno scripts/start-long-running.ts",
6873
"audit:tailwind": "esno scripts/audit-tailwind-colors",
6974
"validate:versions": "./scripts/validate-versions.sh",
7075
"knip": "knip",

scripts/perf-events.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Performance test: run HighVolumeEventWorkflow which generates a mixed
3+
* history of activities, timers, child workflows, and signals.
4+
*
5+
* Usage:
6+
* pnpm perf:events # 40 000 target events
7+
* pnpm perf:events --target 10000 # custom target
8+
* pnpm perf:events --no-worker # use already-running worker
9+
*/
10+
11+
import yargs from 'yargs/yargs';
12+
13+
import { connect } from '../temporal/client';
14+
import { runWorker, stopWorker } from '../temporal/worker';
15+
import { HighVolumeEventWorkflow } from '../temporal/workflows';
16+
17+
const argv = await yargs(process.argv.slice(2))
18+
.option('target', {
19+
type: 'number',
20+
default: 40_000,
21+
describe: 'Target history event count',
22+
})
23+
.option('worker', {
24+
type: 'boolean',
25+
default: true,
26+
describe: 'Start embedded Temporal worker',
27+
})
28+
.parse();
29+
30+
const TARGET: number = argv.target;
31+
const WORKFLOW_ID = `perf-events-${Date.now()}`;
32+
33+
async function main() {
34+
console.log('\n🚀 Temporal mixed-event perf test');
35+
console.log(` Target events : ${TARGET.toLocaleString()}`);
36+
console.log(
37+
' Mix : activities · timers · child workflows · signals',
38+
);
39+
console.log(` Workflow : ${WORKFLOW_ID}\n`);
40+
41+
if (argv.worker) {
42+
console.log('⏳ Starting embedded worker...');
43+
await runWorker();
44+
}
45+
46+
const client = await connect();
47+
48+
console.log('⏳ Starting HighVolumeEventWorkflow...');
49+
const handle = await client.workflow.start(HighVolumeEventWorkflow, {
50+
taskQueue: 'e2e-1',
51+
workflowId: WORKFLOW_ID,
52+
args: [TARGET],
53+
});
54+
55+
console.log(`✅ Workflow started: ${handle.workflowId}`);
56+
console.log(
57+
`\n⚙️ Generating ${TARGET.toLocaleString()} history events...\n`,
58+
);
59+
60+
const startMs = performance.now();
61+
let lastLogMs = startMs;
62+
let lastCount = 0;
63+
64+
const poller = setInterval(async () => {
65+
try {
66+
const desc = await client.workflow.describe(WORKFLOW_ID);
67+
const count = desc.historyLength ?? 0;
68+
const now = performance.now();
69+
const elapsed = ((now - startMs) / 1000).toFixed(1);
70+
const rate = Math.round((count - lastCount) / ((now - lastLogMs) / 1000));
71+
const pct = Math.min(100, Math.round((count / TARGET) * 100));
72+
console.log(
73+
` [${elapsed}s] ${count.toLocaleString()} / ${TARGET.toLocaleString()} events (${pct}%) · ${rate.toLocaleString()} ev/s`,
74+
);
75+
lastLogMs = now;
76+
lastCount = count;
77+
} catch {
78+
// workflow may not be describable yet
79+
}
80+
}, 3000);
81+
82+
const result = await handle.result();
83+
clearInterval(poller);
84+
85+
const totalMs = performance.now() - startMs;
86+
87+
console.log(`\n✅ Done in ${(totalMs / 1000).toFixed(1)}s`);
88+
console.log('\n📊 Workflow result:');
89+
console.log(` History events : ${result.historyLength.toLocaleString()}`);
90+
console.log(` Activities : ${result.activities.toLocaleString()}`);
91+
console.log(` Timers : ${result.timers.toLocaleString()}`);
92+
console.log(` Child workflows: ${result.children.toLocaleString()}`);
93+
console.log(` Signals recv'd : ${result.signals.toLocaleString()}`);
94+
if (result.durationMs) {
95+
console.log(
96+
` Workflow span : ${(result.durationMs / 1000).toFixed(1)}s`,
97+
);
98+
console.log(
99+
` Event rate : ${Math.round(result.historyLength / (result.durationMs / 1000)).toLocaleString()} ev/s`,
100+
);
101+
}
102+
console.log(`\n Workflow ID: ${WORKFLOW_ID}`);
103+
console.log(' → Load in UI to test fast-history performance\n');
104+
105+
if (argv.worker) {
106+
await stopWorker();
107+
}
108+
}
109+
110+
main().catch((err) => {
111+
console.error(err);
112+
process.exit(1);
113+
});

0 commit comments

Comments
 (0)