Task: Conflate a burst into one window and one frame
Table of Contents
This page documents a task in the Stream the changes as they happen story. It captures the goal, current status, acceptance, and any notes or results.
1. Goal
A burst costs one frame however it falls across a tick, and a stream that was too slow to read holds one frame per collection rather than one per window.
2. Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Stream the changes as they happen |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-11-04 |
3. Acceptance
[X]A change opens the window; a quiet system opens none.[X]A burst that starts between ticks is one frame, not two.[X]A stream that was not read holds one frame for its collection, the newest.[X]Two collections a stream missed are still two frames.
4. Plan
Two halves of one property, so they change together.
The window opens on the first change. The ticker re-arms a fixed interval whatever happened, so a burst that starts just after a tick is cut in two: the screen redraws twice for one import. The coalescer calls a hook when its dirty set goes from empty to non-empty, the ticker arms its own timer from that call, and a tick that finds nothing to arm does not pump. A long burst is still a series of windows, because a window is never extended by what arrives inside it; what changes is that the burst's first window begins with the burst.
The change arrives on the NATS dispatch thread and the timer belongs to the server's, so the arm is a post: one allocation per change and no timer touched from two threads.
The pending frame for a collection is overwritten. A slow stream queues every window it missed, and the bytes it holds are for a collection that has moved again since. The subscription keeps one pending frame per collection and a new frame for the same collection replaces it, moved to the back so the ids a client sees stay ascending. That is a small map of collection to frame plus a list for the order, not a second queue.
The capacity keeps its meaning as "collections pending", and the drop policy stays drop-oldest: a stream that is behind loses the frame it is least able to use.
5. Measurements
Every number this task claims about a tree, with the command that produced it
and the commit it was measured at. A number without both is not a measurement,
because a clean-up record describes a tree and trunk moves under a branch: a
consumer count, a gate's component count and a suite's size all change with no
code changing, and the reader of a bare number cannot tell which tree it came
from. Where the raw output matters, put it in an evidence/ file beside the
task and name that file in the Command column.
| What | Value | Command | Commit |
|---|---|---|---|
| Coalescer, pump, stream and ticker cases | 62 cases, 232 assertions, all passing | ./compass.sh test run -- -R "ores.http.core.tests" -VV |
3ea4a2eb53 |
| The window a burst opens | the 100 ms window closes 140 ms after the first change even though a second change landed at 80 ms | ./ores.http.core.tests "[change_ticker]" |
3ea4a2eb53 |
6. Notes
- The arm is a post because the change arrives on the publishing thread and the
timer belongs to the server's. A change recorded before
runhas started finds no executor to post to, so the loop's first pass readspending()and arms the window itself; the case that records before the loop runs covers that path. - The loop's idle sleep is one hour and is not a policy: it only bounds how long a quiet server stays asleep, because a change expires the timer as it arms.
- The conflation key is tenant, party and collection joined with a separator the parts cannot contain. It is built by the hub, so a publisher states the collection its frame is about and nothing more, and a stream that hears two tenants cannot have one replaced by the other.
- A replay conflates too: a client that missed three frames of one collection is given the newest, which is the same answer it would have reached by refetching.
- The capacity still means collections pending, and the drop policy is still drop-oldest.
7. Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario)
that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
8. PRs
| PR | Title |
|---|---|
| #4460 | [http] Complete the change stream: identity, party, conflation |
9. Review
Review round 1, on PR #4460: one bot review with five points and four minor notes. Every comment has a decision; the ones taken are in the commits after the first push.
| # | Comment summary | File | Decision | Notes |
|---|---|---|---|---|
| 1 | Frame ids restart with the process, so a stale Last-Event-ID can replay wrongly | change_hub.hpp, change_routes.cpp | Applied | An id now carries the mark of the process that issued it: the process start in nanoseconds, a dash, and the sequence. A mark this process did not issue is answered with one resync, which is the gap case. Three cases pin it: the id grammar, a stranger's mark, and the replay bound. |
| 2 | publish() reads next_id_ outside the lock | change_hub.hpp | Applied | The id is taken inside the locked block and used after it, so two publishers cannot stamp the same frame. |
| 3 | A burst larger than the subscription capacity is dropped without a resync | change_hub.hpp | Applied for a replay, declined for a live drop | A replay the stream cannot hold whole is answered as no replay at all: one resync, one case. A live drop still follows the design's policy, drop the oldest and let the client refetch, and the policy's other half, marking the collection dirty again, belongs with the coalescer and is not built. |
| 4 | stop() before run() leaves the loop running forever | change_ticker.cpp | Applied | run() reads the owner's stop as well as the window's, so a stop with no executor to post to is honoured. One case calls stop() before the loop starts. |
| 5 | window fields are unsynchronised, so they depend on a single-threaded executor | change_ticker.hpp | Applied | The class states the requirement: one thread at a time, a single-threaded io_context or a strand. The flags stay plain, because the design gives each core one thread. |
| 6 | TenantAdmin and SuperAdmin are string literals | change_routes.cpp | Declined | ores.http.core depends on ores.iam.core, not ores.iam.api, where those constants live. Adding the API library to the core for two names is the wrong direction, and the names are the token's contract; if they should be shared, the constants belong where both may include them. |
| 7 | stoull("-1") wraps, and 12abc reads as 12 | change_routes.cpp | Applied | The hub parses the id grammar itself and refuses anything else, so the route hands an unreadable id to the hub as a gap instead of guessing a number. |
| 8 | The resync frame carries no id | change_hub.hpp | Declined | A resync is not a change. Stamping it would move the client's Last-Event-ID past frames it never saw, which is worse than answering a second reconnect with a second resync. |
| 9 | No live stream was run against a deployment | Accepted as a limitation | Already recorded on the PR. Points 1 and 3 are the ones a live run would settle, and neither is reachable from the suites alone. |
10. Result
A burst is one frame per collection however it falls across the clock, because the window is armed by the burst's first change and nothing inside it extends the window. A stream that fell behind holds the newest frame for each collection it missed, with the ids still ascending, so a client that resumed from the last id it read is never told to go backwards.