Skip to content

/history and /since load an object's entire version chain into memory, unpaged #252

Open

Description

@thehabes

Re-scoped 2026-09-24. This issue originally covered queryHeadRequest, getAllVersions, and the version endpoints together. The /query half is now resolved — see What this no longer covers at the bottom. What remains is the version chain.

Summary

/history/{id} and /since/{id} load every version of an object's tree into memory and serialize all of it into one response. Neither accepts limit or skip, and neither emits the Pagination-* headers or the rel="next" Link that /query and /search have had since #310 and #314.

The shared root cause is getAllVersions() (controllers/utils.js:472):

ls_versions = await db.find({ "__rerum.history.prime": { $eq: rootObj['@id'] } }).toArray()

No limit, no skip. It reads the entire tree regardless of how much of it the caller will use. getAllAncestors() and getAllDescendants() then walk that array in memory and the whole result is handed to res.json().

Evidence

Measured on production, 2026-09-24, read-only

The tree rooted at 5c9534ecd5de4de2a7acde11 is a linear chain of 410 versions — 409 distinct previous values, every next of length 1, exactly one leaf.

Request Response Size Time
GET /v1/since/5c9534ecd5de4de2a7acde11 409 objects 11.18 MB 1.52s
GET /v1/history/5ca36ca7d5de4de2a7ad0417 (the leaf) 409 objects 11.18 MB 1.48s

An 11 MB unpaged response per request, and there is no way for a client to ask for less:

GET /v1/history/{id}?limit=1&skip=0  -> 200, no Pagination-* headers, no rel="next"
GET /v1/since/{id}?limit=1&skip=0    -> 200, no Pagination-* headers, no rel="next"

Cost is quadratic, and recursion depth tracks chain length

getAllAncestors() (controllers/utils.js:490) and getAllDescendants() (:525) each scan the full ls_versions array to find one neighbour, then recurse. On the chain above that is 409 recursive frames and roughly 168,000 comparisons to answer one request. The walk is not tail-recursive, so a sufficiently long chain reaches the stack limit rather than returning a slow answer.

From the original load testing (Run 4, March 2026)

  • History Tree Attack: 50 VUs hitting /v1/history/{id} on an object with 100+ versions for 3 minutes. Each request loaded 100+ documents into memory simultaneously.

That contributed to the memory growth documented in #251, which has since been closed.

The constraint any fix has to respect

getAllVersions() has five call sites, and only two of them are the paged read path:

Call site Needs
controllers/history.js:35 (/since) a page
controllers/history.js:70 (/history) a page
controllers/delete.js:190 the whole tree, for correctness
controllers/utils.js:562 (establishReleasesTree) the whole tree, for correctness
controllers/utils.js:622 (healReleasesTree) the whole tree, for correctness

So the original recommendation — "add a limit to getAllVersions" — cannot be applied to the function as it stands. Capping it would silently truncate the release tree and the delete walk, turning a memory problem into a data-integrity one. The read path and the integrity walks have to be separated first.

Proposed change

  1. Give /history and /since the same pagination contract the other read endpoints now have: getPagination() for limit/skip, the Pagination-* headers, and setNextPageLink() for rel="next". Both already import from controllers/utils.js, so the pieces are in place.
  2. Split the read path off from the integrity walks. The ancestor chain and the descendant set can each be resolved by a query against __rerum.history.previous / __rerum.history.next rather than by pulling the whole tree and filtering it in application memory — the same move /search paginates in application memory, so every page costs the server everything up to that page #309 made for /search.
  3. Leave delete and the two release-tree functions reading the full tree until they are addressed on their own terms. They are correctness-critical and are not what this issue is about.
  4. Document the new contract for both endpoints in public/API.html.

Cursor-based continuation for deep paging is tracked separately in #303 and is not required here.

What this no longer covers

Activity

  1. changed the title [-]Query and version endpoints load unbounded results into memory[/-] [+]`/history` and `/since` load an object's entire version chain into memory, unpaged[/+] on Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions