This document tracks the evolution of Act's performance optimizations with benchmark data. For current patterns and strategies, see the README.
All PostgreSQL benchmarks run against a local instance on port 5431. Each benchmark uses vitest bench with default iterations. Numbers vary between runs — focus on relative improvements, not absolute values.
A small set of hot-path scenarios runs on every PR via pnpm -F @rotorsoft/act bench:run && pnpm -F @rotorsoft/act bench:check. The check compares against a checked-in baseline (libs/act/perf-baseline.json); a scenario fails CI if its p50 exceeds 1.5× the baseline.
To refresh the baseline (only when the slowdown is intentional):
pnpm -F @rotorsoft/act bench:update # writes perf-baseline.json…and commit the change in a PR labeled perf-baseline-update with rationale in this document.
| Scenario | p50 | ops/sec | effective |
|---|---|---|---|
action: single commit |
2.4 ms | 426 | — |
load: warm cache hit |
1.2 ms | 848 | — |
load: cold replay 100 events |
1.2 ms | 821 | — |
action+load roundtrip |
3.7 ms | 273 | — |
| 50 concurrent commits (different streams) | 3.6 ms / batch | 278 batches/sec | ~13,900 commits/sec |
| 20 contended commits (same stream, with retries) | 2.5 ms / batch | 401 batches/sec | ~8,000 commits/sec |
- Single-stream throughput (one user/aggregate at a time): bounded by
actionp50. ~430 commits/sec on InMemoryStore. - Cross-stream throughput (many independent aggregates): scales with the event loop's parallelism. ~13,900 commits/sec on InMemoryStore at 50-way parallelism.
- Same-stream contention (e.g. multiplayer game shared room): bounded by optimistic-concurrency retries. ~8,000 commits/sec for 20 contending users on InMemoryStore. Real-world stores will be slower (network/disk-bound).
- All numbers are InMemoryStore at
NODE_ENV=test(sleepMs=0). Production stores trade absolute throughput for durability — seelibs/act-pg/bench/*.{micro,scenario}.bench.tsfor Postgres numbers (claim, drain, watermark, contention).
⚠ Synthetic upper bounds. Real apps with invariants, multi-event commits, and reactions firing typically see 30–60% of these numbers. See "Realistic workloads" below for measurements that include those costs.
The sensitive-data slices (#855) add machinery to every event that flows through the orchestrator — a pii_fields(name) registry lookup, fields.length === 0 early-exit branches in pii_merge/pii_gate/pii_strip, and the gating path in action()'s post-commit snapshot builder. Events without sensitive(...) markers should pay nothing, but "should" needed measurement.
libs/act/bench/sensitive.micro.bench.ts exercises the orchestrator's hot paths on a plain non-sensitive Counter — no sensitive markers, no .discloses, no actor arg on load. Ran the same bench on master (pre-#855) and on the merged #855 branch:
| Scenario | Master (before) | #855 (after) | Δ |
|---|---|---|---|
app.do() single commit |
419 hz / 2.39 ms | 426 hz / 2.35 ms | +1.6% |
app.load() over 100 events |
840 hz / 1.19 ms | 856 hz / 1.17 ms | +1.8% |
app.do() + app.load() round-trip |
275 hz / 3.64 ms | 283 hz / 3.53 ms | +3.0% |
rme was ±1–2% on every measurement, so the deltas are within run-to-run noise. The PII machinery imposes no measurable overhead on non-sensitive workloads. The early-exit short-circuits are doing their job — apps that don't opt into the feature don't pay for it.
Apps that do mark sensitive fields pay a per-event cost that's bounded by the number of sensitive keys (1–3 typical): one Object.keys pass for the split, one for the gate, one for the strip. None of those routines allocate when there are no sensitive fields on the event.
Run with pnpm -F @rotorsoft/act bench:realistic. These exercise the full pipeline real apps pay for: payload validation, invariant checking, multi-step workflows, reaction dispatch through correlate→drain, and projection updates. Numbers are not in the CI regression guard — they're for capacity planning.
| Scenario | p50 | per-iter | effective |
|---|---|---|---|
| Ticket workflow: open → assign → close (3 actions, 3 events, 2 invariants) | 7.2 ms | 138 workflows/sec | ~414 commits/sec |
| Calculator session: 10 key presses + projection updating (correlate+drain) | 32.3 ms | 31 sessions/sec | ~310 commits/sec |
| Shared inventory: 10 contending reservations (same stream, invariant + retries) | 2.6 ms | 394 batches/sec | ~3,940 commits/sec |
| Question | Synthetic upper bound | Realistic |
|---|---|---|
| Single-stream sequential commits | 430 /sec (action: single commit) |
414 /sec (3-step ticket workflow with invariants) |
| Same-stream contention | 8,000 /sec (no invariants, no reactions) | 3,940 /sec (with stock > 0 invariant) — ~50% |
| Multi-action with reactions firing | not measured (reactions skipped in regression guard) | 310 /sec (10 actions + correlate + drain) — the drain cost is real |
Takeaway: the regression guard's synthetic numbers are useful for catching framework slowdowns. For capacity planning, use the realistic numbers — particularly the calculator session, since "many actions + projection updating" matches most CRUD-style apps.
Multi-process stress harness against a real Postgres instance. Different from the InMemoryStore guards above: this exercises true OS-level concurrency, real FOR UPDATE SKIP LOCKED semantics, and adapter-specific failure modes the in-process tests can't reach.
Runs on every push to master (and weekly via cron) via .github/workflows/stress.yml. Results post to the workflow run's Job Summary so they're one click from any GitHub user.
To run locally:
docker run -d --name pg-stress -p 5431:5432 -e POSTGRES_PASSWORD=postgres postgres:17-alpine
pnpm -F @rotorsoft/act-pg stress| Scenario | Workers | What it stresses | Invariants asserted |
|---|---|---|---|
commit-storm |
8 | High commit rate across non-overlapping streams | Per-stream versions strictly monotonic from 0; no duplicates; total events = sum of worker successes |
same-stream |
8 | All workers race on one stream with retries | Versions monotonic; no duplicates at same version; every commit eventually lands via ConcurrencyError retries |
drain-under-churn |
4 + 4 | Half committing while half drain via claim/ack |
Versions monotonic; no duplicates; no leases held past lease window; total drained = total committed |
killed-worker |
6 + 2 | 2 workers process.exit(1) mid-commit |
Versions monotonic; no duplicates; no stuck leases; surviving workers continue cleanly |
Latest results land in the workflow Summary. The harness found and forced a fix for one race in this PR: PostgresStore.commit now converts PG unique-violations on (stream, version) into ConcurrencyError so callers retry on the framework signal rather than an adapter-specific error.
PR: #460 — Introduced always-on InMemoryCache (LRU, maxSize 1000) to eliminate full event replay on load().
Cache stores the latest state checkpoint per stream. On load(), only events committed after the cached position are replayed. On action(), the cache is updated after every successful commit. Concurrency errors invalidate stale entries.
An alternative design would update the cache only at snap boundaries. We benchmarked both:
Cache on every commit (chosen):
| Events | No snap | @10 | @50 | @75 | @100 |
|---|---|---|---|---|---|
| 50 | 4,872 | 5,881 | 6,480 | 7,058 | 6,949 |
| 500 | 6,371 | 5,639 | 5,590 | 6,223 | 5,488 |
| 2,000 | 4,257 | 5,329 | 4,573 | 4,812 | 4,039 |
Cache only on snap (rejected):
| Events | No snap | @10 | @50 | @75 | @100 |
|---|---|---|---|---|---|
| 50 | 608 | 5,845 | 6,098 | 694 | 1,006 |
| 500 | 212 | 6,481 | 4,955 | 570 | 5,074 |
| 2,000 | 101 | 6,827 | 5,993 | 675 | 4,039 |
The snap-only strategy fails for states without .snap() (falls back to full replay) and has cache misses between snap boundaries. Cache-on-commit costs one Map.set() per commit but guarantees every load() after the first action is a cache hit.
Compared to pre-cache baselines (PG, no cache):
| Events | Without cache | With cache | Speedup |
|---|---|---|---|
| 50 | 655 | 4,872 | 7x |
| 500 | 215 | 6,371 | 30x |
| 2,000 | 92 | 4,257 | 46x |
InMemoryStore note: InMemory benchmarks cap at ~830 ops/s because every method starts with
await sleep(0)to simulate async behavior. The event-loop yield costs ~1ms per call.
PR: #471 — Replaced two-phase poll→lease drain cycle with atomic claim() using PostgreSQL's FOR UPDATE SKIP LOCKED.
The old drain cycle used two separate store calls: poll() to discover available streams, then lease() to lock them. Between these calls, another worker could grab the same stream — a race condition that wasted cycles.
claim() fuses both into a single SQL transaction using FOR UPDATE SKIP LOCKED — the PostgreSQL idiomatic competing consumer pattern. Workers never block each other; locked rows are silently skipped. This is the same pattern used by pgBoss, Graphile Worker, and Oban.
Also replaced lease(leases, 0) in correlate() with subscribe() — a clean upsert for registering reaction target streams.
Separate Act instances sharing the same PostgresStore connection pool, each with its own drain lock — simulating distributed workers competing for streams through the same database.
Each configuration seeds N streams with 5 events each, then runs W concurrent drain loops until all streams are processed. Throughput = total acked streams / wall-clock time. Waste = drain cycles that found no work (wasted DB round-trips).
| Config | poll→lease (streams/s) | claim (streams/s) | poll→lease waste | claim waste | Improvement |
|---|---|---|---|---|---|
| 1w × 100s | 1,271 | 1,790 | 0% | 0% | 41% faster |
| 1w × 500s | 5,731 | 5,202 | 0% | 0% | ~same |
| 3w × 100s | 1,081 | 892 | 11% | 13% | ~same |
| 3w × 500s | 3,439 | 4,222 | 13% | 12% | 23% faster |
| 5w × 100s | 507 | 590 | 14% | 17% | 16% faster |
| 5w × 500s | 2,244 | 4,424 | 17% | 7% | 97% faster, waste halved |
Key findings:
- The improvement scales with load — at 5 workers × 500 streams,
claimis 97% faster and waste drops from 17% to 7% - With the old poll→lease, workers would poll the same streams, then compete at the lease phase — many lose and waste the cycle
- With
claim(FOR UPDATE SKIP LOCKED), each worker atomically grabs different streams in one query — no wasted discoveries - At low concurrency (1 worker), the improvement comes from eliminating one DB round-trip per drain cycle
- At high concurrency, the improvement compounds: fewer wasted cycles × fewer DB round-trips × zero contention blocking
The Store interface was also simplified:
| Before | After |
|---|---|
poll(lagging, leading) |
(removed) |
lease(leases, millis) |
(removed) |
| — | claim(lagging, leading, by, millis) |
| — | subscribe(streams) |
Net reduction of 139 lines in the first commit, plus cleaner separation of concerns: claim for drain, subscribe for correlate, ack/block for finalization.
PR: #472 — Advancing correlation checkpoint + eager static subscription.
The framework already handles long streams efficiently — once a stream is subscribed, the per-stream watermark (at) ensures claim() + drain picks up new events without needing correlate. And start_correlations() already advances its scan position between ticks.
However, settle() passed a static { after: -1, limit: 100 } to correlate on every call, re-scanning the same early events and re-evaluating all resolvers (static and dynamic) against already-subscribed targets. While harmless (subscribe is idempotent), this was wasted work — especially for apps with only static resolvers where correlate adds no value.
Three optimizations working together:
-
Resolver classification at build time — each reaction is tagged as static (object resolver) or dynamic (function resolver). Static resolvers have a known target at build time; dynamic resolvers depend on event data.
-
Eager static subscription — static resolver targets are subscribed once at init via
store().subscribe(). The subscribed set is tracked in-memory. Correlate never re-evaluates static resolvers. -
Advancing checkpoint initialized from watermarks — on cold start,
max(at)from the streams table provides the starting position (no new checkpoint storage needed). After init, the checkpoint advances vialast_idfrom correlate.settle()andstart_correlations()use the shared checkpoint.
Cold start:
_init_correlation()readsmax(at)from existing subscription watermarks- Subscribes all static targets (idempotent upsert — one query)
- Sets checkpoint to
max(at)
Ongoing (with dynamic resolvers):
correlate()scans only from checkpoint, only evaluates dynamic resolvers- Skips events already scanned, skips static resolvers entirely
- Checkpoint advances to
last_id
Ongoing (static resolvers only):
correlate()returns immediately — no event scan, no DB querysettle()goes straight todrain()
Apps with only static resolvers (_this_, .to("target")) — correlate is skipped entirely.
| Events | Before (ms/cycle) | After (ms/cycle) | Speedup |
|---|---|---|---|
| 100 | 2.73 | 0.38 | 7.2x |
| 500 | 2.60 | 0.39 | 6.7x |
| 2,000 | 1.93 | 0.23 | 8.4x |
Apps with dynamic resolvers — checkpoint advances past already-scanned events.
| Events | Before (ms/cycle) | After (ms/cycle) | Speedup |
|---|---|---|---|
| 100 | 3.09 | 0.56 | 5.5x |
| 500 | 4.67 | 0.43 | 10.9x |
| 2,000 | 2.36 | 0.33 | 7.2x |
First correlate after bootstrap — reads max(at) from watermarks.
| Events | Before (ms) | After (ms) | Speedup |
|---|---|---|---|
| 100 | 3.8 | 4.0 | ~same |
| 500 | 5.3 | 2.8 | 1.9x |
| 2,000 | 14.8 | 7.6 | 1.9x |
The cold-start checkpoint is read via subscribe() which now returns { subscribed, watermark } — the watermark (max at across all subscriptions) is computed internally by each store adapter alongside the upsert, in a single transaction. No new Store methods, no new tables or columns.
PR: #474 — Skip caught-up streams in claim().
claim() returned all available (unblocked, unleased) streams regardless of whether they had pending events. In steady state, most streams are caught up — drain would claim them, fetch events, find nothing, and ack with the same position. Wasted work that scales with total stream count.
Add an EXISTS subquery to the available CTE in claim() that checks for events beyond the stream's watermark. Newly subscribed streams (at < 0) bypass the filter — they always need their first drain.
WHERE blocked = false
AND (leased_by IS NULL OR leased_until <= NOW())
AND (s.at < 0 OR EXISTS (
SELECT 1 FROM events e
WHERE e.id > s.at
AND e.name <> '__snapshot__'
AND (s.source IS NULL OR e.stream = COALESCE(s.source, s.stream))
LIMIT 1
))Key: uses = (not ~ regex) for the stream match, which leverages the (stream, version) unique index. Source-less subscriptions (projections) match any event.
| Config | Baseline claimed | Filtered claimed | Baseline (ms/cycle) | Filtered (ms/cycle) | Improvement |
|---|---|---|---|---|---|
| 50 total, 5 active | 500 | 5 | 19.1 | 2.4 | 8x faster |
| 200 total, 10 active | 2,161 | 12 | 23.2 | 6.9 | 3.4x faster |
| 500 total, 10 active | 5,209 | 18 | 21.3 | 13.0 | 64% faster |
| 500 total, 50 active | 5,416 | 58 | 24.0 | 15.6 | 35% faster |
The filter eliminates wasted claims — only streams with pending events are returned. At 200 streams with 10 active, claim returns 12 instead of 2,161 (216x fewer), and the drain cycle is 3.4x faster.
PR: #484 — Skip drain when committed events have no registered reactions.
drain() runs the full claim → query → ack cycle (3 DB round-trips) even when none of the recently committed events have registered reactions. For apps where projections handle only a subset of event types (e.g., 7 lifecycle events out of 18 total), ~61% of drain cycles do no useful work.
- Build-time:
_reactive_eventsset collects event names with at least one registered reaction in theActconstructor - In
do():_needs_drainflag set when a committed event name matches_reactive_events(O(1)Set.has()) - In
drain(): return empty result immediately when_needs_drainis false — zero DB round-trips - Flag cleared when drain completes with nothing acked, blocked, or errored, or when claim returns no streams
- Cold start: flag set in
_init_correlation()to ensure historical events are processed
maxPasses defaults to Infinity and acts as a kill-switch for runaway reaction loops. settle() exits naturally when a pass makes no progress (no new subscriptions, no acks, no blocks), so the cap rarely matters in practice — paginated catch-up after app.reset(...) works without manual loops.
Simulates a realistic entity with 18 event types where only 7 lifecycle events have registered reactions. The remaining 11 operational events skip drain entirely.
| Scenario | ops/s | mean (ms) | Speedup |
|---|---|---|---|
| Operational event (drain skipped) | 92 | 10.9 | — |
| Lifecycle event (full drain) | 26 | 38.2 | — |
| Mixed burst (3 ops + 1 lifecycle) | 16 | 64.5 | — |
| Operational vs lifecycle | 3.51x faster |
The 27ms saved per non-reactive cycle corresponds to the 3 DB round-trips (claim + query + ack) that are eliminated. In production with network latency to a remote database, the savings would be proportionally larger.
| Scenario | ops/s | mean (ms) |
|---|---|---|
| Non-reactive event (drain skipped) | 281 | 3.6 |
| Reactive event (full drain) | 109 | 9.2 |
| Improvement | 2.58x faster | 5.6ms saved |
Purely internal to Act — two new private fields (_reactive_events, _needs_drain), no Store interface changes.
Issue: #556 — Optional .batch() handler on projections for bulk event processing.
Projections process events one at a time during drain — each handler call is an independent async operation. When replaying large streams (rebuilding a projection, deploying a new read model, catching up after downtime), this produces N sequential writes instead of 1 batched transaction.
With PostgreSQL, a single transaction wrapping N writes is dramatically faster than N individual writes. The framework's per-event handler loop in handle() made it impossible to batch without working around the framework.
Add an optional .batch() method to the projection builder (static-target projections only). When defined, drain() calls the batch handler once with the full ordered array of all event types instead of calling individual .do() handlers per event.
const TicketProjection = projection("tickets")
.on({ TicketOpened })
.do(async ({ stream, data }) => { /* single-event fallback */ })
.on({ TicketClosed })
.do(async ({ stream, data }) => { /* single-event fallback */ })
.batch(async (events, stream) => {
// ALL events in one transaction — one DB round-trip
await db.transaction(async (tx) => {
for (const event of events) {
switch (event.name) {
case "TicketOpened": /* ... */ break;
case "TicketClosed": /* ... */ break;
}
}
});
})
.build();Key design decisions:
- Projection-level, not per-event — one handler for all event types in a single transaction
- Always called when defined — even for a single event, no conditional switching
- Static-target only —
.batch()available only onprojection("target"); the Act class mapstarget → batchHandlerat build time - Discriminated union types —
BatchEvent<TEvents>distributesCommittedover each key, enablingswitch (event.name)to narrow bothnameanddata - Batch error = total rollback — if the handler throws,
handled: 0and watermark does not advance
Events are pre-seeded; only the drain call is timed. Per-event handlers simulate N × 1ms async writes; the batch handler simulates 1 × 1ms for the entire batch.
| Events | Per-event drain (ms) | Batched drain (ms) | Speedup |
|---|---|---|---|
| 50 | 62.7 | 4.8 | 13x |
| 200 | 231.8 | 5.9 | 39x |
| 500 | 573.2 | 5.9 | 97x |
Per-event drain scales linearly (N × ~1.15ms per handler call). Batched drain is constant (~5ms) regardless of event count — one handler call plus framework overhead. The speedup is proportional to event count.
Each handler performs a real INSERT ... ON CONFLICT DO UPDATE against a PG table. Per-event makes N individual writes; batched wraps all N in a single transaction.
| Events | Per-event drain | Batched drain | Speedup |
|---|---|---|---|
| 1,000 | 5.7s | 294ms | 19.5x |
| 5,000 | 27.5s | 1.4s | 19.4x |
| 10,000 | 54.1s | 3.0s | 17.8x |
Consistent ~19x improvement across event counts. The speedup comes from eliminating per-write transaction overhead (implicit BEGIN/COMMIT per row) and reducing network round-trips. Per-event scales linearly (~5.4ms/event); batched scales sub-linearly (~0.3ms/event) thanks to PG's transaction batching.
| New API | Description |
|---|---|
projection("target").batch(handler) |
Register a batch handler for bulk event processing |
BatchEvent<TEvents> |
Distributive discriminated union type for batch handler events |
BatchHandler<TEvents> |
Type for batch handler functions |
Projection.target |
Static target string, exposed on the Projection type |
Projection.batchHandler |
Optional batch handler, exposed on the Projection type |
How long does it take for a reaction to fire after app.do()? Architects evaluating Act for time-sensitive workflows ask this first; this section answers it for the single-process case. Cross-process latency (writer and reader on different boxes, both on PG) is in @rotorsoft/act-pg/PERFORMANCE.md.
Three steady-state scenarios. Each one wires up a single reaction whose handler records performance.now() - committedAt per event. Commits are spread across 256 source streams to avoid serialized contention on a single stream's version.
| Scenario | Driver | Notes |
|---|---|---|
| idle | one commit at a time, await reaction, repeat | Measures the floor — settle debounce + correlate + drain + handler |
| low | 100 commits/sec sustained for 3 s | Realistic interactive workload |
| high | 1000 commits/sec sustained for 3 s | Stress test — reveals where InMemory saturates |
Settle runs on every committed event with debounceMs: 0 so the reaction wake-up follows the local fast path (do() → arm drain → settle).
Run: pnpm bench:scenarios libs/act/bench/reaction-latency.scenario.bench.ts
Numbers below are from a single run on macOS 25.4 (Apple Silicon), no other load. Variance ±20 % — the order-of-magnitude is the meaningful thing.
| Scenario | p50 | p95 | p99 | Notes |
|---|---|---|---|---|
| idle | 7 ms | 8 ms | 8 ms | Floor ≈ settle debounce + drain cycle |
| low (100/s) | 8 ms | 12 ms | 14 ms | Within striking distance of idle |
| high (1000/s) | ~1.8 s | ~3.0 s | ~3.0 s | InMemory single-threaded drain saturates — reactions queue |
Same scenarios, same hardware, against the docker PG instance on
localhost:5431. Variance is higher than InMemory because PG round-trips
add their own jitter (autovacuum, OS scheduling, transient disk I/O).
Run: pnpm bench:scenarios libs/act-pg/bench/reaction-latency.scenario.bench.ts
| Scenario | p50 | p95 | p99 | Notes |
|---|---|---|---|---|
| idle | 4 ms | 20 ms | 500 ms | p50 close to InMemory; tail dominated by single PG outliers (small sample) |
| low (100/s) | 10 ms | 22 ms | 70 ms | PG roundtrip ~5 ms baked into commit + drain |
| high (1000/s) | ~125 ms | ~1.2 s | ~1.5 s | Saturates faster than InMemory — PG ack overhead under concurrent commits |
Reading the PG tail. The idle p99 of ~500 ms is a single PG-side outlier (autovacuum kicking in, transient lock wait, etc.) magnified by the small sample count (~50–80 events). p50 is the meaningful stat for steady-state planning; p99 carries operator-facing tail-risk weight only at higher commit volumes. The framework-side regression bound asserts on p50 < 50 ms for that reason.
- The floor is ~10 ms. Settle is debounced (default 10 ms) and drain claims one batch per cycle. For interactive workloads (≤ 100 commits/sec on InMemory), latency stays close to the floor.
- InMemory saturates around 200 commits/sec sustained. The 1000/sec scenario clearly shows the system can't keep up — every event waits 1–3 s in the settle queue. This is where multi-process scale-out (PG + workers) becomes structurally necessary, not just nice-to-have.
- Hardware-dependent. Re-run the script on your target hardware before quoting numbers in production planning. The script is deterministic and self-contained.
The InMemory adapter optimizes for development feedback loops — fast cold-start, no schema, no docker. For production-grade single-process workloads:
- At ≤ 100 commits/sec: InMemory is fine if you accept ephemeral state (no persistence across process restart). Most apps need PG for durability anyway, so the latency comparison is moot.
- At > 100 commits/sec sustained: PG with single-process settle still saturates similarly because the bottleneck is the framework's drain cycle, not the store. Bigger throughput needs horizontal scale-out — see
@rotorsoft/act-pg/PERFORMANCE.mdon cross-process notify and ACT-102 priority lanes.
- Browser → server → reaction round-trip — that's an app-level concern (network, framework, etc.), not framework latency.
No Store interface changes. Batching is handled entirely at the Act orchestrator level.
The framework reads the _v<digits> versioning convention from the merged event registry and auto-marks legacy versions as deprecated (see event-schema-evolution.md). The runtime piece is a single check in action() after the event tuples are computed: if me._deprecated is non-empty, scan the emitted names against the Set; warn once per name per process if any match.
The concern: this check runs on every app.do() call. Quantify the cost.
Run: pnpm bench:micro libs/act/bench/deprecation-check.micro.bench.ts
| Config | hz | mean | rme |
|---|---|---|---|
| No deprecation in registry | 425.11 | 2.35 ms | ±0.64% |
| With deprecation in registry (emits current version) | 423.15 | 2.36 ms | ±0.58% |
1.00× — statistically indistinguishable. The 0.5% delta sits inside the measurement noise (rme ±0.58–0.64%). The "with deprecation" config exercises the actual check (Set.has lookup for the emitted event name) and still doesn't move the needle.
- Common case bails on the first read. Most production states have no
_v<n>siblings, some._deprecatedisundefined→ one property read + one truthy check → branch out before any loop or Set work. Zero ops per call. - Active deprecation path is two Set lookups. When the state DOES carry a non-empty
_deprecatedset, the per-emit cost is onedeprecated.has(name)+ (on hit) onewarned.has(name). Both are O(1). The Zod validation that runs immediately after is dramatically more expensive — this check is rounding error. - Warning is idempotent. Once an event name has been warned about, the
warned.hascheck short-circuits and no logger call fires. Steady-state cost after first warn = same two Set lookups, no I/O.
The cost is below measurement noise — pinning a regression bound would be pinning noise. The check is structurally O(1) per emit and the bench is here to document the empirical floor, not to gate CI.
ActOptions.scoped lets an Act use its own { store, cache } instead of the singletons. The framework threads the bag via AsyncLocalStorage so internal store()/cache() calls resolve transparently. Two concerns to quantify:
- Per-call port read.
store()now doesscoped.getStore()?.store ?? _store()on every lookup. Does the ALS check tax the hot path? - Method-level wrap. Public Act methods wrap their body in
scoped.run({store, cache}, fn)when the Act is scoped, no-op otherwise. Does the wrap cost show up end-to-end?
Run: pnpm bench:micro libs/act/bench/scope-overhead.micro.bench.ts
| Config | hz | mean | rme |
|---|---|---|---|
store() — no active scope (falls through to singleton) |
14.82M | 67 ns | ±0.04% |
store() — inside scoped.run() (returns scoped bag) |
15.11M | 66 ns | ±0.25% |
cache() — no active scope |
15.32M | 65 ns | ±0.05% |
cache() — inside scoped.run() |
15.10M | 66 ns | ±0.18% |
Within ±1%. Modern Node's AsyncLocalStorage.getStore() is essentially a property read off the current async resource — the overlay is invisible against the cost of the getter itself.
| Config | hz | mean | rme |
|---|---|---|---|
Unscoped Act (no-op wrap: (fn) => fn()) |
425.13 | 2.35 ms | ±0.58% |
Scoped Act (real wrap: scoped.run(bag, fn)) |
427.47 | 2.34 ms | ±0.60% |
1.01×. The action pipeline (validate, load, patch, commit, cache) dwarfs the wrap by four orders of magnitude. The scoped.run binding is free at this granularity.
| Config | hz | mean | rme |
|---|---|---|---|
| Unscoped Act | 844.60 | 1.18 ms | ±0.76% |
| Scoped Act | 844.78 | 1.18 ms | ±0.90% |
1.00×. Load reads store() and cache() multiple times per call — maximum exposure to the overlay — and still shows no movement.
AsyncLocalStoragein Node ≥ 16 reads from the activeAsyncResource's storage map — a single property lookup, not a context-tree walk.- The wrap is per-method, not per-port-read. One
scoped.runperapp.do()/app.load()call vs. potentially many internal port lookups inside it; amortizes to nothing. - No async-hook side effects.
AsyncLocalStorageno longer enables async_hooks process-wide in modern Node — only the storage is hooked.
Same reasoning as ACT-403: the cost is below measurement noise, so a baseline would pin noise. Bench retained as evidence that the overlay is structurally free.
Shipped in
@rotorsoft/act@0.45.0(May 2026). See #733.
The orchestrator builds one DrainController per active lane (implicit default + every .withLane(...)). Act._drainAll runs every controller's drain() in parallel via Promise.all and aggregates fetched/leased/acked/blocked. Per-lane cycleMs autostarts a setTimeout chain that calls the controller's drain() at the lane's cadence — independent of the Act-level settle loop.
Three perf questions:
- Zero regression for zero-lane apps. With no
.withLane(...), the single implicit controller passeslane: undefinedtoclaim()— adapter SQL collapses to the pre-1103 shape. - Bounded multi-lane overhead. With 4 lanes active,
_drainAllcalls four parallel claims. Each filtered claim is served fromstreams_lane_ixon durable adapters. - Fast-lane responsiveness under slow-lane backpressure. When a slow handler holds the slow lane for 100 ms+, the fast lane keeps acking events on its own timing budget — the actual user-facing benefit of lanes.
Workload: commit 100 events on distinct streams, correlate() once, time drain() until settled. 30 timed iterations after 5 warmups against Docker PG. Each iteration re-primes (drain is destructive). Two back-to-back runs reported because the delta sits inside the noise floor.
| Configuration | p50 | p95 | mean | drains/sec |
|---|---|---|---|---|
1 lane (no withLane) — pre-1103 path |
60.4–60.8 ms | 67.1–69.9 ms | 60.6–61.9 ms | 16–17 |
| 4 lanes (default + 3 declared) | 62.3–63.2 ms | 67.4–68.0 ms | 62.4–63.1 ms | 16 |
+1% to +4% mean across two runs — within run-to-run variance. The lane-filtered claim() serves from streams_lane_ix; four parallel claims add up to the same total work the single all-lanes claim was doing. Production cost of the fan-out is essentially the controller-map walk.
Same workload against the in-memory adapter, which has no index — every claim() scans every subscribed stream and rejects those outside its lane.
| Configuration | p50 | p95 | mean | drains/sec |
|---|---|---|---|---|
1 lane (no withLane) — pre-1103 path |
53.4 ms | 55.5 ms | 53.4 ms | 19 |
| 4 lanes (default + 3 declared) | 57.2 ms | 58.5 ms | 57.1 ms | 18 |
+7% mean. Cost is the Map filter inside InMemoryStore.claim(). Reference for the upper bound when no index is available.
Workload: commit 5 events on slow streams (handler sleeps 100 ms) + 50 events on fast streams (no-op handler), correlate(), then loop drain() until done. Measure fast-event latency from "drain may begin" to "fast handler fires" — commit serialization excluded so the number reflects orchestrator responsiveness only. 6 iterations, Postgres, two runs.
| Configuration | p50 | p95 | p99 | mean |
|---|---|---|---|---|
Single controller (no withLane) |
130–133 ms | 134–140 ms | 134–140 ms | 131–133 ms |
Two lanes (slow + fast) |
17.0–18.6 ms | 17.9–20.9 ms | 18.0–20.9 ms | 17.0–18.1 ms |
~7× faster fast-lane responsiveness under slow-lane backpressure. With a single controller, slow + fast streams share one Promise.all dispatch — the cycle's ack waits for the slowest handler (the 100 ms sleep) before fast events ack. With two lanes, _drainAll's Promise.all runs each controller's drain in parallel; the fast lane's handlers run, ack, and free their leases independently while the slow controller is still working. This is the user-facing benefit lanes were introduced for.
- The 1-lane / single-controller rows are the regression guardrail. A future change that makes them slower than the pre-1103 baseline reaches for an exception, not an excuse —
lane: undefinedis the legacy path and must stay that way. - The fan-out cost (Bench 1/2) is informational. Operator decisions to declare lanes are made on latency-class grounds (slow webhook vs fast notification), not on drain throughput per cycle.
- The responsiveness number (Bench 3) is the actual headline. Single-controller fast latency tracks the slow handler's duration; lane separation collapses it back to the no-op floor.
- No CI baseline. The fan-out path is new and a regression baseline would lock in run-to-run variance. Re-run when touching
DrainControlleror the lane filter SQL.
withLane({cycleMs: 100}) auto-starts a setTimeout chain on the controller that calls its own drain() at the configured cadence. The timer uses unref() so it doesn't keep the process alive on its own; shutdown() clears it. Useful for "always-on" lanes that should drain continuously regardless of whether the application explicitly calls settle() — a fast lane with cycleMs: 10 will reach commit-to-ack latency near 10 ms without the caller having to drive the loop. Not benchmarked separately because the responsiveness bench above already pumps drain in a tight loop; the worker exists for apps that don't.
NODE_ENV=test LOG_LEVEL=fatal pnpm tsx libs/act-pg/scripts/lane-overhead.ts # bench 1 — PG headline
NODE_ENV=test LOG_LEVEL=fatal pnpm tsx libs/act/scripts/lane-overhead.ts # bench 2 — InMemory reference
NODE_ENV=test LOG_LEVEL=fatal pnpm tsx libs/act-pg/scripts/lane-responsiveness.ts # bench 3 — fast-lane latency