Search: reindex outbox poller (claim, build, push, cascade)
Part of #2610
Adds the consumer side of incremental search indexing. reindex_poller command drains the reindex_outbox that the capture side writes: it claims ready rows, rebuilds each affected artifact document with ArtifactDocumentBuilder, and pushes the results to OpenSearch through the _bulk API.
How it works
Each cycle runs in order. A watchdog pass first recovers rows a dead worker left stuck in processing. A short claim transaction then takes the next batch with SELECT ... FOR UPDATE SKIP LOCKED (direct artifact edits before cascade work, oldest first) and commits before anything is built, so a document always reflects post-claim database state. Claimed ids are deduplicated and built fresh per cycle, then pushed in bounded bulk requests. A row is marked done only when every one of its ids succeeded, a row with a failed id retries with backoff (30s, 2m, 8m) and parks as a visible failed dead-letter after three attempts.
Cascade changes
A change to a related record (a renamed collection, a period, an author) arrives as a lazy outbox row. A resolver maps each of the 16 document-affecting entity tables to the exact artifact set it touches, including the multi-hop location-to-provenience path, both provenience roles, hierarchy descendant walks, and the four ways an author reaches a document. Expansion writes those ids as child rows in one transaction with the parent's completion, so a crash mid-expansion re-expands cleanly, and a direct edit enqueued mid-cascade is still processed before the remaining children.
Single instance by mechanism
Only one poller runs at a time, enforced by a named database lock (GET_LOCK) on a dedicated connection held for the life of the process. A second instance, including an operator --once run, exits instead of racing, and the lock frees itself if the process dies. Pushes are idempotent (re-index and re-delete are no-ops, and deleting an absent document is benign), so an over-retry after a crash is safe.
Operations
bin/cake reindex_poller runs the loop (back to back while work exists, idle backoff capped at about 5s). --once runs a single cycle; --max-cycles and --batch bound it for tests and tuning. Daemonization, a health surface, and done-row cleanup come next.
Testing
Unit tests cover the indexer (mocked HTTP), the claim (a two-session SKIP LOCKED no-double-claim proof and the lock semantics), the processor (attribution, the retry machine, the watchdog, the exception paths), the resolver (the exact id set per cascade type), and the command (loop bounds, lock, backoff). An end-to-end suite against local OpenSearch covers the change-to-searchable path and the failure modes: engine down, killed worker, partial bulk, poison row, and cascade fairness.
One test-infra note: tests/bootstrap.php gains a shutdown hook that restores any table left with AUTO_INCREMENT >= 900000000 to its natural MAX(id)+1, so the high-id seeds the capture-side controller tests use no longer leave the local cdli_db counters raised across runs.