review · dynamic mode · 2026-07-26

Blindspot pass: RFC 214, incremental indexing for corpus-index

Kind review Mode dynamic Created 2026-07-26 Owner Ines Vargas Status Draft Read time ~9 min Source canonical-example

In plain terms

Nothing below argues against incremental indexing. The findings are all about the same thing: the nightly rebuild was quietly doing four jobs, and RFC 214 only replaces one of them.

Only the first band changes the decision. The other five findings change the build order and the alerting, which is why they are written down here instead of held back for the implementation review.

The change under review

The nightly rebuild and the alias flip are the parts everyone already works with, so this section only exists to fix the vocabulary the findings use. The single new mechanism in RFC 214 is a consumer that reads the change stream and writes into the live index.

Today, rebuild_all starts at 01:00 UTC, reads all fourteen sources, builds that night's index corpus_v0725, hands off to acl_expand, and flips the corpus_read alias when both finish. RFC 214 keeps that job but runs it once a week, on Sunday, and lets a consumer on the corpus.changes topic apply upserts to the live index in between.

Current vs proposed indexing path

Today: rebuild every night

graph TB A[14 sources] --> B[rebuild_all 01:00] B --> C[corpus_v0725] C --> D[acl_expand] D --> E[flip alias corpus_read]

RFC 214: stream in between

graph TB F[14 sources] --> G[corpus.changes] G --> H[index consumer] H --> I[live corpus_read] J[Sunday: rebuild_all
then acl_expand] -.weekly.-> I
Today

Every document in corpus_read was written by one job, in one pass, from a single read of the sources, and then had its permissions filled in by a second job before the alias moved. Freshness is up to 24 hours behind.

RFC 214

Most documents in corpus_read were written by the consumer, one at a time, in whatever order the topic delivered them, with no second pass. Freshness is seconds behind.

The findings all come from the same seam: the nightly rebuild was a full read of the sources, a delete sweep, a mapping refresh, and the trigger for the permissions pass. Moving it to Sunday keeps the first job and drops the other three for six days at a time.

Band 1: stop

Both of these produce a wrong index rather than a slow one, and neither shows up in a metric that is currently collected.

A restricted document reads as unrestricted until the next Sunday

Band 1: stop Bites: first incremental write Radius: every restricted document between rebuilds

Assumed. RFC 214 lists visible_to among the fields the consumer writes, which reads as though permissions travel with the document like any other field.

Actually. The indexer has never written visible_to. It is filled in by acl_expand, which runs after the rebuild finishes, reads group membership out of directory_snapshot, and updates the already built index in a second pass (jobs/acl_expand.py:118). It takes an index name, not a document id, and there is nothing to call for a single upsert.

What breaks. The search filter shortcuts an absent field to unrestricted: SearchQueryBuilder.java:212 adds the permissions clause only inside an exists guard, so a document with no visible_to matches every query from every account. A restricted page edited on Tuesday is returned to everyone who searches for it until Sunday. It looks healthy the whole time, because it is indexed, it is fresh, and no check compares the field against the source.

Fix now: make acl_expand callable for one document and have the consumer call it, or refuse the write when visible_to is absent. Roughly three days, nearly all of it in the first of those two. Fix after cutover: the same three days, plus reading the search logs to work out which restricted documents were returned and to whom, which is the part with no upper bound.

Archived documents stay searchable for up to seven days

Band 1: stop Bites: first archive after cutover Radius: everything archived between two Sundays

Assumed. Every change to a document arrives on corpus.changes, deletes included.

Actually. The wiki and tickets sources, which are 94% of rows, never hard-delete anything. They set archived = true in place, and the connector drops archived rows before they reach the topic (connectors/wiki.properties:22). An archive therefore produces no event at all. The nightly rebuild hid this completely, because it read the sources with where archived = false and an archived page simply failed to appear in the next index.

What breaks. After cutover the only thing that removes a document is a full rebuild, so a page archived at 09:00 on Monday keeps being returned until the Sunday alias flip: the job starts at 01:00 and takes 5h40m, which puts the flip 5 days and 22 hours after the archive. A page archived just after a flip waits the full seven days. The bad case is not staleness. It is a page that was archived precisely because it should stop being read.

Fix now: stop excluding archived rows and treat archived = true as a delete in the consumer. About two days, including one replay of the last week of events. Fix after cutover: the same code, but there is no event trail for the archives that were already dropped, so finding them means diffing every index id against the source tables, which took a week the last time it was done for a single source.

Band 2: fix in build

These three are all cases where a number in RFC 214 is an average and the failure lives in the tail.

A ticket re-stamp fills the bulk queue and events start dropping after about four minutes

Band 2: fix in build Bites: next workflow migration Radius: silent drift until Sunday

Assumed. The consumer is sized for 40,000 events a day, which is the daily average over the last quarter.

Actually. The tickets connector re-stamps every open ticket whenever a workflow migration runs. The last one, on 2026-04-19, put 2.1 million events on the topic in 25 minutes, about 1,400 a second, against a sizing figure that averages out to under half an event a second.

What breaks. At that rate the write queue on the three indexer nodes fills (it is at the default of 200), the cluster starts rejecting individual bulk items with 429, and RFC 214 step 6 handles a rejection with three attempts and a fixed 200 ms wait before logging the event and moving on. The queue fills in about four minutes. Every event dropped after that is a document that stays stale until Sunday, and no counter exists that would let anyone say how many there were.

Fix now: a dead letter topic and a consumer lag alarm, about two days, both useful whether or not a re-stamp ever happens again. Fix after cutover: the same two days, bought after an incident where the honest answer to "how far off is the index" is that nobody can tell.

A new field waits for the Sunday window

Band 2: fix in build Bites: first mapping change after cutover Radius: one change, up to seven days

Assumed. Mapping changes are out of scope for RFC 214.

Actually. They were in scope of the nightly rebuild. Every new field and every analyser change has shipped by merging a mapping and letting that night's rebuild pick it up. The cluster will not change the analyser on an existing field in place, so once the rebuild is weekly there is no path between Sundays.

What breaks. A filter that needs one new keyword field goes from "merge it and wait a night" to "merge it and wait up to seven days". The predictable response to that wait is writing the value into an existing text field, which is how tickets.status_text ended up holding three unrelated things.

Fix now: keep the build-a-new-index-and-flip path as a job someone can trigger rather than a cron entry. The code is already in jobs/rebuild_all.py; it needs a trigger and a lock, about a day. Fix after cutover: someone reinstates it under time pressure without the alias flip, and every search hits a half-filled index for the 5h40m the build takes.

A revert applied before its edit leaves the old title in the index

Band 2: fix in build Bites: two edits inside one second Radius: single documents, no self-repair

Assumed. Applying events in the order they arrive converges on the source state.

Actually. corpus.changes is partitioned on a hash of source and batch id, not on document id, so two edits to one document can sit on different partitions and be applied concurrently by different consumer tasks.

What breaks. A rename and a revert 300 ms apart can be applied in reverse order, leaving the intermediate title in the index. Both events were consumed successfully, so there is no retry, and no later event exists that would correct it. It repairs itself on Sunday and not before.

Fix now: write with the source row's updated_at as an external version, so the cluster rejects a stale write instead of the outcome depending on which task ran first. Half a day, plus adding updated_at to the connector projection. Fix after cutover: the same change, except a stale entry and a genuinely old document look identical from outside, so every report of a wrong title turns into a manual diff against the source row.

Band 3: accept

Neither of these needs code. They need a number written into RFC 214 so the trade is taken on purpose.

Tombstoned segments add about 1.9 GB a week between rebuilds

Band 3: accept Bites: not at the current size Radius: disk headroom

Assumed. Nothing. RFC 214 does not mention disk, which is what makes this a blindspot rather than a disagreement.

Actually. An in-place upsert marks the previous copy deleted and writes a new one. The space comes back when segments merge, or when the index is rebuilt. At 40,000 upserts a day against a 41 GB index, a week of drift is roughly 1.9 GB before Sunday reclaims it.

Why it stays here. The cluster sits at 62% of 1 TB, so 1.9 GB is noise, and a force merge is available if it stops being noise. The finding is not the gigabytes. It is that the disk alarm thresholds were set when reclaim happened nightly.

Fix now: one sentence in RFC 214 and a threshold that assumes weekly reclaim. An hour. Fix after cutover: also an hour, unless the corpus has tripled first, in which case the number to argue about is 5.7 GB a week and the answer may go the other way.

Rolling back means 5h40m without a correct index

Band 3: accept Bites: only during a rollback Radius: search quality for most of a working day

Assumed. The rollback in RFC 214 is "stop the consumer, run rebuild_all". As a procedure that is complete and correct.

Actually. rebuild_all takes 5h40m, the mean of the last four nightly runs, with a range of 5h21m to 5h58m. For that whole stretch the live index keeps whatever state the consumer left behind, including the wrong permissions from the first finding if that is what triggered the rollback.

Why it stays here. The alternative is running the nightly rebuild in parallel for the first month, which spends the maintenance window RFC 214 exists to reclaim. Taking a slow rollback looks like the better trade, at least at the current corpus size.

Fix now: put the number in the rollback step and agree what triggers it. Half a day of discussion, no code. Fix after cutover: the number gets discovered while a rollback is already running.

What fixing each finding costs

FindingBandFix before cutoverFix after cutover
Restricted document reads as unrestrictedBand 1: stop3 days3 days plus a search log audit of unknown size
Archived document stays searchableBand 1: stop2 days2 days plus about a week of id diffing
Re-stamp drops eventsBand 2: fix in build2 days2 days, after an incident with no drift counter
No mapping path between SundaysBand 2: fix in build1 day1 day, or 40 minutes of half-filled index if improvised
Reverse-order edit sticksBand 2: fix in build0.5 day0.5 day plus a manual diff per report
Weekly tombstone growthBand 3: accept1 hour1 hour
Slow rollbackBand 3: accept0.5 day of discussionDiscovered mid-rollback

Fixing the whole list before cutover is about nine days of work, and only five of those days sit in front of the decision; the rest can run alongside the build.

Checked and found nothing

A pass that reports only hits says nothing about where it looked, so these are the things that were probed and came back clean.

Coverage here is the query path, the window, retention, topology, and steady-state sizing; nothing in this list depends on the two stop-band findings being fixed, so it stays valid whichever way RFC 214 goes.

The checks, as run
$ rg -n 'indexName\(' services/search --stats     # 19 matches, 0 literal index names
$ curl -s "$OS/_cat/aliases/corpus_read?v"
$ curl -s "$OS/_cluster/settings?include_defaults" | jq '.defaults.thread_pool.write'
$ kafka-configs --describe --topic corpus.changes | grep retention.ms   # 604800000
$ psql -c "select count(*) from wiki_pages where archived"              # 1.4M archived rows

The 2026-04-19 burst figure comes from the indexer dashboard for that day, not from these commands.

What this pass did not cover

The ordering of the bands is judgment about how badly each finding bites, not a measurement, and two people ranking the same seven would probably swap the re-stamp finding and the mapping finding.