CenterForDigitalHumanities / CenterForDigitalHumanities/rerum_server_nodejs

`/search` paginates in application memory, so every page costs the server everything up to that page

Open
#309 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
3
Forks
6
Avg merge
1h 25m
Merged PRs (30d)
3

Description

## Summary

`/search` fans out to two Atlas Search indexes as two separate `db.aggregate()` calls, then merges, deduplicates, sorts, and slices the combined result in Node (`controllers/search.js:282`). Each branch caps at `$limit: limit + skip` (`controllers/search.js:211,216`), so a request for page 40 makes Atlas produce and return 4000 documents per index and makes Node process up to 8000 of them in order to hand back 100.

`/query` pushes the same work down to Mongo — `db.find(props).limit(limit).skip(skip)` (`controllers/crud.js:87`) — and its latency is flat with depth. `/search` cannot, and its latency is not.

Nothing about `$search` requires this. A single Atlas Search pipeline sorts by score natively, returns each document once, and can end in `$skip`/`$limit`. The blocker is the two-index fan-out, not the search stage.

This is the performance half of the `/search` work. The correctness half is #307, which should land first and independently — a client can page `/search` honestly once that lands, just not cheaply.

## Why this matters

**Cost scales with depth, not page size.** Dev, `{"searchText":"line"}`, `limit=100`:

```text
skip=0 -> 100 documents in 379ms
skip=1000 -> 100 documents in 664ms
skip=2000 -> 100 documents in 781ms
skip=3000 -> 100 documents in 1172ms
skip=3700 -> 100 documents in 2021ms
skip=3800 -> 25 documents in 1954ms
skip=3900 -> 0 documents in 1784ms
skip=5000 -> 0 documents in 1802ms
skip=20000 -> 0 documents in 1815ms
```

`/query` at the same depths, `{"__rerum.APIversion":{"$exists":true}}`, `limit=100`:

```text
skip=0 -> 100 documents in 340ms
skip=1000 -> 100 documents in 296ms
skip=5000 -> 100 documents in 323ms
skip=20000 -> 100 documents in 369ms
skip=99000 -> 100 documents in 445ms
```

The two seconds spent to return *nothing* at `skip=5000` is the cost of producing and merging the entire matching set. Cost plateaus once the result set is exhausted, which confirms the bound is `min(total matches, limit + skip)` per branch. Production shows the same shape at smaller scale: `line` has 574 matches there, and the empty pages at `skip=1000`, `2000`, and `20000` still cost roughly 850-900 ms each.

**The ceiling is the `skip` maximum.** At the effective maximum of 100000, a sufficiently broad term at maximum depth asks Node to hold on the order of 100000 full RERUM documents per branch in memory and sort them. Not identifiers — whole documents.

**It is a shared-process cost.** This work happens in the API process, not in Atlas. Under PM2 cluster mode a few concurrent deep searches contend for the same workers that serve every other endpoint.

## Affected lines

| File | Line | Current |
|------|------|---------|
| `controllers/search.js` | 82 | `buildDualIndexQueries()` builds two independent pipelines |
| `controllers/search.js` | 84, 153 | Two separate indexes: `presi3AnnotationText`, `presi2AnnotationText` |
| `controllers/search.js` | 211, 216 | `$limit: limit + skip` per branch — the depth cost |
| `controllers/search.js` | 32 | `mergeSearchResults()` deduplicates in Node, after the per-branch cap |
| `controllers/search.js` | 282, 368 | `merged.slice(skip, skip + limit)` — pagination in application memory |
| `controllers/search.js` | 446, 540, 672 | Same shape in the unmounted `searchFuzzily`, `searchWildly`, `searchAlikes` |
| `controllers/crud.js` | 87 | `/query` for contrast: `.limit(limit).skip(skip)` in Mongo |

## Proposed change

Three options, best first. This issue should settle which one before any code is written.

### 1. One combined Atlas Search index (recommended)

Define a single index covering both vocabularies — the IIIF 3.0 paths (`body.value`, `bodyValue`, and the `items` / `annotations` embedded documents) and the IIIF 2.1 paths (`resource.chars`, `resource.cnt:chars`, and the `resources` / `otherContent` / `sequences` embedded documents).

`/search` becomes one `$search` with all the existing `should` clauses, followed by `$skip` and `$limit`:

- **Ranking becomes native.** Atlas returns `$search` results in descending score order across all clauses, and every document is scored once against one index rather than compared across two.
- **Deduplication disappears.** Atlas returns each document exactly once; there is no key to derive and nothing to drop. `mergeSearchResults()` can be deleted, and with it both bugs from #307.
- **Paging runs in the database**, so cost stops scaling with depth.

Cost: an Atlas-side index change, and both index generations must exist during the transition.

### 2. `$unionWith` in a single pipeline

Keep both indexes, run the IIIF 2.1 branch as a `$unionWith` sub-pipeline against the same collection, then `$group` on `_id`, `$sort` by score, and `$skip`/`$limit` — all server-side. Grouping on `_id` in Mongo compares structurally, so embedded-object ids group correctly there.

This moves the work off the API process but not off the cluster: the `$group` is a blocking stage over the union, so Atlas still materializes the merged set. Smaller change, smaller payoff. **Confirm `$search` is permitted as the first stage of a `$unionWith` sub-pipeline on our cluster tier before committing to this** — it is not universally available.

### 3. Keep the shape, bound the damage

If neither restructuring is scheduled soon, enforce a `/search`-specific `skip` maximum far below the `/query` maximum, since the two endpoints pay very different prices for the same depth. This is a mitigation, and it makes the two endpoints inconsistent — exactly what the shared-contract work argues against — so it should be a deliberate, documented decision rather than a default.

## Notes

- **Breaking.** Option 1 changes relevance scoring, and should: a single index scores a document once across all matched clauses. Today the two branches' scores are never compared against each other at all, so ordering will shift substantially. That is the point, not a regression. Membership should change only by the currently dropped documents reappearing.
- Depends on #308. Reviewing a new index definition against two that exist only in a browser tab is not a review.
- Should land after #307, which is the cheap correctness fix and is not blocked by anything. If option 1 ships, it supersedes both of that issue's fixes — but it should not gate them.
- `rel="next"` (#302) needs `/search` to over-fetch one record. Under the current shape that is `limit + skip + 1` per branch; under option 1 it becomes the ordinary single-pipeline `limit + 1`.
- Once this lands, `/search` can take a real cursor. Atlas Search's `searchAfter` / `searchSequenceToken` is the keyset equivalent for `$search` results — **confirm availability on our cluster tier and server version before planning it.** See #303 for why a cursor before this lands would be dishonest.
- Whatever lands must keep the property already verified: sequential pages equal to a single larger request, in the same order.

## Acceptance criteria

- [ ] An option is chosen and the reasoning is recorded on the issue before implementation
- [ ] `/search` pagination is applied in MongoDB rather than in application memory
- [ ] `/search` latency at depth is flat with respect to `skip`, comparable to `/query`, measured with the same probes used above
- [ ] Every document matched by the search is reachable by paging, including documents whose `_id` is an embedded object
- [ ] Results are ordered by descending relevance score, verified by asserting the score sequence never increases
- [ ] Sequential paged results still equal a single larger request, in the same order
- [ ] Relevance ordering change is measured on representative queries and accepted before cutover
- [ ] `/search` and `/search/phrase` both covered, and the unmounted search variants updated to match
- [ ] The ordering guarantee is stated in `openapi/contracts/core-provider.openapi.yaml`, not only in JSDoc

Contributor guide

Open the contributing guide

Research direction

Start with controllers/search.js, especially buildDualIndexQueries(), mergeSearchResults(), and the /search and /search/phrase handlers; compare their pagination with controllers/crud.js:87. Inspect the index definitions from #308 and the contract in openapi/contracts/core-provider.openapi.yaml, then run the documented deep-page probes. Done means an agreed option is implemented and measured, with coverage for ordering, embedded-object IDs, sequential pages, both mounted endpoints, and the unmounted variants.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, mongodb
Domain
api, backend, database, performance
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.