Commit a69f416
fix(redis): bound the three unbudgeted stream writers by bytes (#7568)
* fix(redis): bound the three unbudgeted stream writers by bytes
The copilot stream buffer, the Tables event log and the realtime file-doc
streams were each bounded by entry count and nothing else. An entry cap bounds
how many entries a key holds and says nothing about how large each one is, so a
single writer emitting large entries reaches gigabytes well inside its cap —
which is how one file-edit stream filled a shared Redis under a 100,000-entry
cap and evicted the whole keyspace.
Each writer gets the bound its read semantics allow:
- Copilot's replay buffer is read from a cursor and must stay contiguous, so it
now reserves bytes against per-stream and per-user ceilings inside the same
Lua that appends, and the writer soft-stops persistence on refusal rather
than failing the live stream.
- The Tables event log is a live feed whose readers already refetch on a prune,
so it drops oldest-first once past a byte ceiling — the existing `pruned`
path carries it, with the running total kept in meta under the same TTL as
the bytes it counts.
- The file-doc streams are Yjs deltas replayed in full by a task attaching
later, so dropping the oldest would lose edits and a native MAXLEN bound is
unsafe. Compaction, which folds deltas into a snapshot first, is lossless —
it now triggers on appended bytes as well as entry count.
Also folds `lib/execution/redis-budget.server.ts` into the shared module rather
than leaving two definitions of the same prefix and ceilings writing the same
Redis keys. Key layout and every execution limit are unchanged, and pinned by
test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(realtime): count only deltas toward the compaction byte threshold
Re-seeding the counter with the snapshot's own size left any document larger
than the ceiling permanently over it, forcing a full snapshot append on every
subsequent keystroke — the write amplification the threshold exists to prevent.
The counter measures edit churn since the last fold, so a stream settles at one
snapshot plus that much churn.
Also self-corrects the Tables byte counter whenever its buffer trims to a single
entry, so an independently evicted events key cannot leave the accumulator
over-reporting and pin the buffer at one entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(redis): address review findings on the byte bounds
- Measure ceilings in UTF-8 bytes on both the copilot and Tables paths, so the
TypeScript checks bound a stream the same way the Lua's `string.len` does
rather than under-reporting every non-ASCII frame.
- Split an oversized copilot batch on the per-write ceiling instead of refusing
it. A flush carries whatever accumulated since the last one, so a run of large
frames can exceed the ceiling collectively while each frame is individually
writable; refusing that stopped replay for the rest of the stream over a
batching artefact. A single frame past the ceiling is still refused.
- Re-check the copilot soft stop when an in-flight append resolves, not only at
enqueue, so a batch queued behind a refusal cannot land and leave replay
holding later events but not the refused ones.
- Deduct rather than zero the file-doc compaction counter, and only once the
trim succeeds, so a failed fold leaves the trigger armed and a concurrent
publish's bytes survive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(redis): account for deltas a fold retains, and release cleared counters
The compaction counter was a single total, so a fold deducted bytes for entries
`XTRIM MINID` had retained — anything published past the fold boundary the
tailer had not yet integrated. Those bytes are still in Redis, so the trigger
disarmed while the stream kept growing. Deltas are now tracked as `{id, bytes}`
and dropped only once a trim provably removed them.
Arming the trigger on retained bytes would be the opposite fault: a fold that
reclaims nothing would re-arm immediately and force a full snapshot append per
publish. Only bytes at or before the fold boundary arm it, and because that
boundary moves in the tailer rather than on publish, the tailer re-checks it —
otherwise a burst of large edits followed by silence would sit unfolded until
the next keystroke.
Also releases the copilot owner counter when the buffer is cleared, crediting
the user counter by exactly what the owner held. Those keys are deleted rather
than expired, so the counter otherwise outlived its data and a retry reusing the
streamId would be refused against bytes that no longer exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(redis): make buffer cleanup atomic and match MINID's inclusive boundary
Deleting a copilot buffer and releasing its reservation were two round trips, so
a concurrent append landing between them kept its events stored with its
reservation already erased. Both now run in one script, composed from a rendered
release fragment the same way the reservation is.
`XTRIM MINID upTo` is inclusive — it keeps the entry whose id equals the
boundary. Accounting treated that entry as folded, so its bytes stopped counting
while they were still in Redis, and a large paste landing exactly on the
boundary could leave the trigger disarmed. Both directions now use the same
strict/inclusive split: only entries strictly before the boundary arm the
trigger, and only those are dropped once a trim removes them.
Replaces the tests' `any` casts with a typed accessor, per the repository's
TypeScript rule. This immediately caught injected test rooms missing
`pendingDeltas`, which made compaction throw into its catch while the assertions
still passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(redis): never credit the shared user counter from a buffer delete
An owner id is not proof of who wrote the bytes, so crediting the user counter
on clear let anyone able to name a stream decrement a ceiling they never
charged. That is the one direction that must not be possible: a counter driven
down grants writes rather than denying them.
The clear now drops the owner counter only. The user counter's fixed window
settles it instead — it already tolerates accruing bytes Redis has dropped, and
this is the same over-count bounded by the same window. The scope threading that
existed only to credit it is removed with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(redis): keep counters outliving their data, and stop a failed fold retrying hot
The copilot owner counter used a fixed one-hour window while the stream TTL is
configurable and defaults to exactly that. Raising COPILOT_STREAM_TTL_SECONDS
would have let the counter expire under live data, and the next write would see
zero reserved and grant another full ceiling. The window is now the larger of
the two, so a counter can never expire before what it accounts for.
A failed fold deliberately leaves the trigger armed, but the snapshot XADD lands
before the XTRIM — so a persistent trim failure retried immediately, appending a
full-document snapshot every time and turning a Redis blip into the write
amplification the threshold exists to prevent. A forced fold now waits out a
cooldown after a failure. The entry-count path is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(realtime): adopt byte accounting for a stream taken over
A room attaching to an existing stream started from an empty ledger, so a
multi-megabyte stream under the entry threshold stayed unfolded while that
room's own heartbeat kept refreshing its TTL — a restart or handoff could hold
one open indefinitely.
`catchUp` already reads every entry to rebuild the doc, so adopting their bytes
costs no extra work. Plain deltas only: a compaction snapshot is the result of a
fold rather than something a fold can reclaim, so counting one would arm the
trigger against itself. The trigger is re-checked once, after catch-up, since
nothing else re-checks until the next local publish and a read-only participant
never makes one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(realtime): mark a fold's output explicitly, and account in the tailer
A fold of an agent-only stream is stamped with the agent marker to preserve the
no-persist guarantee, which made it indistinguishable from an ordinary agent
preview frame. Excluding that marker from accounting therefore dropped preview
deltas — the largest payloads there are, and the ones that caused the incident —
while including it would let a snapshot arm the trigger against its own output.
A dedicated field settles it without touching origin selection.
With the ambiguity gone, accounting moves from the publish path to `applyEntry`,
which observes every entry the room tails: this task's appends, a peer task's,
and one published with no room attached anywhere. Publish-side accounting could
only ever see local writes, and contributed nothing to the trigger before the
tailer caught up regardless, since only entries at or before the fold boundary
arm it. The ledger is now a Map keyed by entry id, so an entry observed twice is
recorded once.
Entries written before this field carry no marker and count as deltas, which
over-arms by at most one fold that then trims them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(redis): drop the clear-buffer script and the unused error class
A variadic DEL is already a single atomic command, so the Lua script and the
render function that built it achieved nothing a plain `del(events, seq, abort,
ownerBudget)` does not. The keys carry no hash tag either, so the script had the
same cluster-slot constraint it appeared to avoid.
Also deletes `RedisBudgetExceededError`, which was defined and never thrown, and
consolidates four rounds of stacked comments in the fold down to the one that
still describes the code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>1 parent 99d69af commit a69f416
16 files changed
Lines changed: 1439 additions & 208 deletions
File tree
- apps
- realtime/src/handlers
- sim/lib
- copilot/request
- lifecycle
- session
- core/redis
- execution
- realtime
- table
- uploads/utils
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
128 | 128 | | |
129 | 129 | | |
130 | 130 | | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
131 | 156 | | |
132 | 157 | | |
133 | 158 | | |
| |||
338 | 363 | | |
339 | 364 | | |
340 | 365 | | |
341 | | - | |
| 366 | + | |
342 | 367 | | |
343 | 368 | | |
344 | 369 | | |
| 370 | + | |
| 371 | + | |
345 | 372 | | |
346 | 373 | | |
347 | 374 | | |
348 | | - | |
| 375 | + | |
349 | 376 | | |
350 | 377 | | |
351 | 378 | | |
| |||
391 | 418 | | |
392 | 419 | | |
393 | 420 | | |
394 | | - | |
| 421 | + | |
395 | 422 | | |
396 | 423 | | |
397 | 424 | | |
398 | | - | |
| 425 | + | |
399 | 426 | | |
400 | 427 | | |
401 | 428 | | |
402 | 429 | | |
403 | 430 | | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
404 | 656 | | |
405 | 657 | | |
406 | 658 | | |
| |||
414 | 666 | | |
415 | 667 | | |
416 | 668 | | |
417 | | - | |
| 669 | + | |
418 | 670 | | |
419 | 671 | | |
420 | 672 | | |
| 673 | + | |
| 674 | + | |
421 | 675 | | |
422 | 676 | | |
423 | 677 | | |
424 | | - | |
| 678 | + | |
425 | 679 | | |
426 | 680 | | |
427 | 681 | | |
| |||
580 | 834 | | |
581 | 835 | | |
582 | 836 | | |
583 | | - | |
| 837 | + | |
584 | 838 | | |
585 | 839 | | |
586 | 840 | | |
| 841 | + | |
| 842 | + | |
587 | 843 | | |
588 | 844 | | |
589 | 845 | | |
590 | | - | |
| 846 | + | |
591 | 847 | | |
592 | 848 | | |
593 | 849 | | |
| 850 | + | |
| 851 | + | |
594 | 852 | | |
595 | 853 | | |
596 | 854 | | |
597 | | - | |
| 855 | + | |
598 | 856 | | |
599 | 857 | | |
600 | 858 | | |
| |||
0 commit comments