feat: message pruning - #1875
Merged
Merged
Conversation
isekovanic
requested review from
MartinCupela,
oliverlaz,
santhoshvai,
szuperaz and
vishalnarkhede
as code owners
September 17, 2026 12:14
6 tasks
…runing # Conflicts: # src/pagination/paginators/BasePaginator.ts # src/pagination/paginators/MessageIntervalPaginator.ts # test/unit/pagination/paginators/MessagePaginatorWindowCap.test.ts
MartinCupela
approved these changes
Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
CLA
Description of the changes, What, Why and How?
A livestream channel that never stops receiving messages grows its loaded window forever. Every publish reprojects the active interval, so that projection is
O(loaded)and the cost of each event climbs with the size of the window. ThestateThrottleMsthrottle already cut how often we publish; this cuts how much each publish costs. They compose, neither replaces the other. Additionally, on platforms like RN this leads to a ton of engaged native views, even if they are not technically rendered/mounted.BasePaginator.pruneTailToLimit(interval)drops the oldest ids untilmaxLoadedItemsremain. The whole thing hinges on where it is called from, soingestItemhands it the interval thatinsertItemIdIntoIntervaljust returned, which is a fresh copy that has not been committed yet. So it mutates something nothing can observe, and thecommitIntervala few lines down stores and publishes the already pruned interval. The window emit after that projects from the same interval. A prune therefore costs no publish of its own and it rides the ones the ingest was going to make anyway andstate.itemsnever even briefly exceeds the cap.The pagination half is the part worth reviewing. Dropping the oldest messages re-opens the tailward edge, so
hasMoreTailandcursor.tailwardhave to move with it, and publishing those separately would be exactly the second notification this design exists to avoid. They ride the window publish instead. But in production that publish is deferred andMessagePaginatordefaultsstateThrottleMs: 500, soingestItemschedules and returns, and the actualpartialNexthappens later influshWindowPublish. We need to derive this from_prunedIntervalIdat commit time, as otherwise due to throttling it might simply lag behind or just be plain wrong.That is really just making the prune behave like everything else here: the throttle is not a queue, nothing is ever handed to it, and
flushWindowPublishrereads_itemIntervalswhen it fires. A cached value was the only thing in that publish that could go stale, which is why it was the only thing that did.We also add
maxLoadedItemsonPaginatorOptions, modeled aroundstateThrottleMs, so it resolves through the existing layering and can be set per instance (channel.messagePaginator.updateConfig({ maxLoadedItems: 100 })), at construction, or declaratively — sharedmessagePaginatorkey withchannel/threadoverrides, so a thread can cap differently from the channel list.setPruningSuspended(bool)is the UI's "hold off" trigger. A UI that knows the user is reading near the oldest loaded message says so and the window is allowed to grow past its cap until they scroll back. Essentially it's the way our UI can hold off pruning (for example if we're at the top of a list or something).Only an anchored interval that is both the dataset head and the active one, so a jumped away window stays stable and a logical (live-only) window, which has no pagination provenance and could never be re-fetched, is declined. Messages the server does not know about are skipped, not stopped at so that a failed send sorts by the time it was composed, so a positional cap would destroy it. Skipping leaves the window a couple of items over at worst and the next arrival trims it again. The cursor comes from the oldest server-confirmed id for the same reason, otherwise a client-generated id goes out as
id_lt. But this is better than letting these die out.Additionally, every id leaving the interval goes through
_itemIndex.remove, which ismemberIds.deleteplusstore.unlink, and the store drops content only when the last subscriber lets go. A pruned message still held by an open thread or the pinned list keeps its content and the offline DB is never touched deliberately.Changelog
maxLoadedItemsto bound a paginator's loaded window, settable per instance, at construction, or declaratively per surfaceMessageIntervalPaginator.setPruningSuspendedso a consumer can hold pruning off while the user reads near the oldest loaded message