Skip to content

schedule event flush - #1052

Open
Devin T. Currie (DTCurrie) wants to merge 1 commit into
ws3-coalescefrom
ws3-flush
Open

schedule event flush#1052
Devin T. Currie (DTCurrie) wants to merge 1 commit into
ws3-coalescefrom
ws3-flush

Conversation

@DTCurrie

Copy link
Copy Markdown
Member

Applies coalesced world state changes under a per-frame budget, carrying whatever does not fit to the next frame, so a burst of changed entities costs a bounded slice of each frame instead of one long one. Keeps draining while the tab is hidden. Stacks on the coalescing PR (ws3-coalesce).

World state

  • drainWithBudget in budgetedFlush.ts applies pending changes in insertion order and stops after the entry that crosses FLUSH_BUDGET_MS (6 ms) or after FLUSH_MAX_SPAWNS (16) spawns and destroys, whichever comes first. It deletes each entry before applying it, so an apply that throws drops its entry instead of wedging the queue.
  • createFlushScheduler in flushScheduler.ts runs the flush on requestAnimationFrame while the document is visible and on a HIDDEN_FLUSH_INTERVAL_MS (250 ms) timer while it is hidden, with an idempotent request and a cancel.
  • WorldState swaps its own requestAnimationFrame bookkeeping for the scheduler, drains through drainWithBudget, reports the remainder as the stats folder's Backlog, and requests another frame while anything is left. applyChange reports whether it spawned or destroyed, which is what the spawn cap counts.

Why?

Why a spawn cap next to a millisecond budget?

The expensive part of a spawn or destroy is the Threlte mount or teardown that Svelte runs in the microtask checkpoint after the callback returns. A wall-clock budget measured inside the callback cannot see it, so the cap bounds it by count.

Why keep flushing while hidden?

requestAnimationFrame pauses in a hidden tab. The pending map is bounded by entity count, so this is about latency on return, not memory: a 250 ms timer keeps the scene current enough that switching back does not replay a backlog.

Testing

Ran pnpm check, pnpm test, and pnpm lint through verify-changed. Added budgetedFlush.spec.ts, with an injected clock showing the budget stopping mid-map and resuming next frame, and flushScheduler.spec.ts, driving both the frame and the hidden-timer paths through injected deps. I did not run pnpm e2e:robot.

@changeset-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4606ebf

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

This PR includes changesets to release 1 package
Name Type
@viamrobotics/visualization 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 Sep 3, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://viamrobotics.github.io/visualization/pr-preview/pr-1052/

Built to branch gh-pages at 2026-09-03 22:12 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@DTCurrie

Copy link
Copy Markdown
Member Author

Claude (@claude) review

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished Devin T. Currie (@DTCurrie)'s task in 5m 30s —— View job


Review

  • Read CLAUDE.md and gather context
  • Read changed files
  • Diff against base branch
  • Review: budgetedFlush.ts
  • Review: flushScheduler.ts
  • Review: WorldState.svelte
  • Review: pendingTransformChanges.ts
  • Review: tests
  • Post final review

The design is clean and well-motivated. drainWithBudget is straightforward and the injected-deps pattern on createFlushScheduler makes both modules genuinely testable. Tests cover the non-obvious boundary cases (one-past budget semantics, spawn cap, throw-drops-entry, cancel-then-rearm). A few issues follow, one of which is a correctness bug.

Bug

flush() doesn't reschedule after a throw from drainWithBudget (see inline comment on WorldState.svelte:268–284 and the companion note on flushScheduler.ts:12–16)

drainWithBudget pre-deletes each entry then re-throws if apply throws (confirmed by the test at budgetedFlush.spec.ts:76). flushScheduler.run() clears both handles before calling deps.flush(), so when flush() throws, no future rAF or timer is scheduled and any remaining pending entries are stuck until the next stream event calls scheduler.request(). In a static scene (stream idle), they never drain. A try/finally in flush() that calls scheduler.request() whenever pending.size > 0 fixes it.

Design concern

Scheduler and budget types don't belong in pendingTransformChanges.ts (inline)

FlushScheduler + FlushSchedulerDeps are the contract for flushScheduler.ts alone. FlushBudget, DrainResult, and ApplyOutcome belong alongside budgetedFlush.ts. Leaving them all in pendingTransformChanges.ts grows that file into a secondary types hub and muddies its single responsibility (the pending-map data model + topLevelField).

Nits

  • ApplyOutcome.spawned is true for destroys (inline). The interface comment explains it, but a name like lifecycleOp would be self-documenting everywhere.
  • ADDED destroy + respawn counts as 1 lifecycle op but queues 2 (inline). The spawn cap is slightly loose for the REMOVED-then-ADDED republish pattern.
  • Test comment at budgetedFlush.spec.ts:13 is borderline under the "no comments in tests" rule (inline).

Observation

There is no visibilitychange listener, so when the tab returns to the foreground the scheduler stays in timer mode until the next timer fires (up to 250 ms). The PR description calls this out explicitly as intentional. Worth keeping as a known limitation if there's ever a complaint about noticeable latency on tab-switch.

Comment on lines 268 to 284
const flush = () => {
const start = performance.now()
const applied = pending.size
for (const [uuid, change] of pending) {
applyChange(uuid, change)
}
pending.clear()

if (applied > 0) invalidate()
stats.recordFlush({ start, end: performance.now(), applied, backlog: 0 })
}

const scheduleFlush = () => {
if (flushScheduled) return
flushScheduled = true
const result = drainWithBudget(pending, applyChange, {
now: () => performance.now(),
budgetMs: FLUSH_BUDGET_MS,
maxSpawns: FLUSH_MAX_SPAWNS,
})

rafId = requestAnimationFrame(() => {
rafId = 0
flushScheduled = false
flush()
if (result.applied > 0) invalidate()
stats.recordFlush({
start,
end: performance.now(),
applied: result.applied,
backlog: result.remaining,
})
if (result.remaining > 0) scheduler.request()
}

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.

Bug: throw from drainWithBudget leaves remaining entries stuck.

drainWithBudget deliberately re-throws when apply throws (the entry is pre-deleted so it won't wedge on the next drain, but the throw propagates — see the test at budgetedFlush.spec.ts:76). When that throw bubbles up through flush, scheduler.request() on this line is never reached. The rAF/timer handle was already cleared in run() before deps.flush() was called, so nothing re-schedules the drain. Any entries that were still in pending sit there until a new stream event arrives and calls scheduler.request() from consumeChanges. In a static scene (stream idle), those entries never drain.

Suggested fix — wrap the drain in a try/finally so the reschedule is guaranteed:

Suggested change
const flush = () => {
const start = performance.now()
const applied = pending.size
for (const [uuid, change] of pending) {
applyChange(uuid, change)
}
pending.clear()
if (applied > 0) invalidate()
stats.recordFlush({ start, end: performance.now(), applied, backlog: 0 })
}
const scheduleFlush = () => {
if (flushScheduled) return
flushScheduled = true
const result = drainWithBudget(pending, applyChange, {
now: () => performance.now(),
budgetMs: FLUSH_BUDGET_MS,
maxSpawns: FLUSH_MAX_SPAWNS,
})
rafId = requestAnimationFrame(() => {
rafId = 0
flushScheduled = false
flush()
if (result.applied > 0) invalidate()
stats.recordFlush({
start,
end: performance.now(),
applied: result.applied,
backlog: result.remaining,
})
if (result.remaining > 0) scheduler.request()
}
const flush = () => {
const start = performance.now()
let result: DrainResult | undefined
try {
result = drainWithBudget(pending, applyChange, {
now: () => performance.now(),
budgetMs: FLUSH_BUDGET_MS,
maxSpawns: FLUSH_MAX_SPAWNS,
})
if (result.applied > 0) invalidate()
stats.recordFlush({
start,
end: performance.now(),
applied: result.applied,
backlog: result.remaining,
})
} finally {
if (pending.size > 0) scheduler.request()
}
}

(You'd need to import DrainResult from pendingTransformChanges.) This also keeps stats.recordFlush inside the try so a throw there doesn't block the reschedule either.

Comment on lines 246 to +248
if (existing) destroyEntity(uuid)
spawnEntity(change.transform)
return
return { spawned: true }

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.

Nit: destroy + spawn counts as one lifecycle op toward the cap, but two are queued.

When an existing entity has a mismatched referenceFrame, destroyEntity is called (queuing Threlte teardown) and then spawnEntity (queuing Threlte mount). The microtask checkpoint after the callback runs both. { spawned: true } counts this pair as one toward FLUSH_MAX_SPAWNS, so the cap is slightly loose for this REMOVED-then-ADDED republish pattern.

This is an edge case and probably fine in practice, but if you want strict bounding here you could return a separate count or a boolean-pair instead of the single spawned flag.

Comment on lines +85 to +100
export interface FlushScheduler {
/** Idempotent: a flush already scheduled is not scheduled twice. */
request(): void
cancel(): void
}

/** The environment a scheduler runs in, injectable so tests can drive it. */
export interface FlushSchedulerDeps {
flush: () => void
isVisible: () => boolean
requestFrame: (callback: () => void) => number
cancelFrame: (handle: number) => void
setTimer: (callback: () => void, ms: number) => number
clearTimer: (handle: number) => void
hiddenIntervalMs: 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.

Design concern: scheduler types don't belong in this file.

FlushScheduler and FlushSchedulerDeps are the contract for flushScheduler.ts — nothing outside those two files needs to import them by name. They'd be more findable and better scoped sitting at the top of flushScheduler.ts.

Similarly, FlushBudget, DrainResult, and ApplyOutcome are the contract for budgetedFlush.ts. WorldState.svelte imports ApplyOutcome from here but only because that's where it landed; moving it to budgetedFlush.ts and adjusting the import is a one-liner.

Leaving them here turns pendingTransformChanges.ts into a secondary types hub. The file already has a clear job (the pending-map data model and topLevelField). The flush types aren't part of that job.


/** What applying one pending change did; a destroy counts as a spawn for budgeting. */
export interface ApplyOutcome {
spawned: boolean

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.

Nit: spawned is true for destroys, which the interface comment has to explain away.

The field is used as a proxy for "a Threlte lifecycle operation was queued", but the name reads as spawn-only. A name like lifecycleOp would be self-explanatory at every callsite without needing the doc comment to bridge the gap.

Suggested change
spawned: boolean
export interface ApplyOutcome {
lifecycleOp: boolean
}

(Touch drainWithBudget.ts and WorldState.svelte to rename outcome.spawnedoutcome.lifecycleOp and spawnslifecycleOps, then update the DrainResult.spawns field too.)

Comment on lines +12 to +16
const run = (): void => {
frameHandle = undefined
timerHandle = undefined
deps.flush()
}

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.

Observation: run clears both handles before calling flush, so a throw in flush leaves no rescheduled work.

frameHandle and timerHandle are both set to undefined here before deps.flush() is called. That's correct for the idempotency check — you don't want a second request() inside flush to be a no-op because the old handle is still set. But it also means that if deps.flush() throws, the scheduler is in a clean-handles state with no future work scheduled. The fix lives on the flush side (see the comment on WorldState.svelte:268), but it's worth knowing the scheduler itself provides no safety net here.


import { drainWithBudget } from '../budgetedFlush'

// The drain never reads the transform, so a stub carrying only the key is enough.

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.

Edge-case: this comment is on the line of the "no comments in tests" rule from testing.md. It survives the rule's narrow exception ("rare domain fact a reader could not infer"), but it is borderline — the stub's type signature and the name makeChange do most of the work. Could drop the comment; the function name says enough.

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.

1 participant