Skip to content

feat: message pruning - #1875

Merged
isekovanic merged 8 commits into
release-v10from
feat/message-pruning
Sep 17, 2026
Merged

isekovanic merged 8 commits into
release-v10from
feat/message-pruning

Conversation

@isekovanic

Copy link
Copy Markdown
Contributor

CLA

  • I have signed the Stream CLA (required).
  • Code changes are tested

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. The stateThrottleMs throttle 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 until maxLoadedItems remain. The whole thing hinges on where it is called from, so ingestItem hands it the interval that insertItemIdIntoInterval just returned, which is a fresh copy that has not been committed yet. So it mutates something nothing can observe, and the commitInterval a 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 and state.items never even briefly exceeds the cap.

The pagination half is the part worth reviewing. Dropping the oldest messages re-opens the tailward edge, so hasMoreTail and cursor.tailward have 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 and MessagePaginator defaults stateThrottleMs: 500, so ingestItem schedules and returns, and the actual partialNext happens later in flushWindowPublish. We need to derive this from _prunedIntervalId at 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 flushWindowPublish rereads _itemIntervals when 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 maxLoadedItems on PaginatorOptions, modeled around stateThrottleMs, so it resolves through the existing layering and can be set per instance (channel.messagePaginator.updateConfig({ maxLoadedItems: 100 })), at construction, or declaratively — shared messagePaginator key with channel / thread overrides, 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 is memberIds.delete plus store.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

  • Added maxLoadedItems to bound a paginator's loaded window, settable per instance, at construction, or declaratively per surface
  • Added MessageIntervalPaginator.setPruningSuspended so a consumer can hold pruning off while the user reads near the oldest loaded message

Comment thread src/pagination/paginators/BasePaginator.ts Outdated
Comment thread src/pagination/paginators/BasePaginator.ts
Comment thread src/pagination/paginators/BasePaginator.ts Outdated
Comment thread src/pagination/paginators/BasePaginator.ts Outdated
…runing

# Conflicts:
#	src/pagination/paginators/BasePaginator.ts
#	src/pagination/paginators/MessageIntervalPaginator.ts
#	test/unit/pagination/paginators/MessagePaginatorWindowCap.test.ts
@isekovanic
isekovanic merged commit d4d2c6c into release-v10 Sep 17, 2026
4 checks passed
@isekovanic
isekovanic deleted the feat/message-pruning branch September 17, 2026 15:57
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.

2 participants