Search: periods indexed, captured, and served from the engine

Periods is the last of the four entities and the smallest, at 32 rows. It is also the only one with no existing search to preserve, so parity means something different here: there is no filter to mirror, and what has to stay identical is the listing itself, the same 32 periods in the same order. The entity is in the pipeline because the cutover needs all four served the same way once Logstash goes, not because it gains a feature.

The document. Three flat fields, text plus a bounded keyword, no analyzers. id is what the read path uses. period is the name, and the column the existing typeahead already searches. period_ar is the Arabic name, from periods_translations at locale = 'ar' with an IFNULL fallback to the English name, which is exactly what the artifact document already does with the same table. Same field name, same semantics, so a cross-entity read finds one spelling rather than two. Left out: sequence, because ordering never leaves MySQL and a sort key here would be a field nothing reads; name and time_range, which nothing reads and which are empty on 5 of the 32 rows; and the associations the view page hydrates from the database.

Capture is two declarations and no new site. The Arabic name lives in a table nothing writes directly, so the obvious worry is that editing it leaves the document stale. It does not. The edit form emits _translations.ar.period next to the base control, and EntitiesUpdatesTable folds a dirty translation into the same changeset row, so a translation-only edit is a periods changeset and the direct row it already writes rebuilds the document. Capture keys on the changeset's table, not on the table the write lands in. So the entity adds OWN_DOCUMENT_ENTITY_TYPES['periods'] = 'period' and a test-side CASCADE_SOURCE_PARENTS['periods_translations'] = ['periods'], with no cascade target, no resolver arm and no capture site moved. Artifacts already rebuild when a period changes.

The read side, with one deliberate difference. One _search returns the ids; MySQL does the IN, the ordering, the paging and the hydration, on the query it already ran. The difference is that the id set is never null. The other three answer null when no recognised parameter arrives, and the database then serves the whole table. Here every request is that request, since there is no parameter, so the ids would never be applied and the page would look the same whether the engine answered, answered nonsense, or was never asked. The engine answers the full set instead.

Parity. The 32 ids in Periods.sequence order, on the engine path, on the database path and against the pre-slice tree, all three matching for /periods, ?sort=period&direction=desc and an unknown sort column. That check is a regression pin on ordering; it would pass just as well against an engine nobody asked. What actually discriminates is in the suite: a stubbed subset renders as that subset, an empty answer renders an empty page, and a document dropped from the index shortens the page by one. This template renders no count and no pager, so a stale document does not show up as a phantom row or a wrong total. The page just loses an item.

Failure handling is the siblings': an unreachable engine logs one warning and serves from the database, a refused query surfaces. There is no invalid-UTF-8 guard here, on purpose. The request body is two constants, so no user string reaches the translator or json_encode() by any route, and a guard could only ever return true. A test pins the body's key set instead, so if the body grows a key the question reopens.

The last commit fixes two defects in shared code, which is why this MR touches Proveniences, Publications and Collections files. SearchClient::post() built its body with (string)json_encode($body), and json_encode returns false for invalid UTF-8 anywhere in the body, so the client posted an empty body, which OpenSearch reads as match_all. LikeWildcard::render() turned preg_split's false into an empty character list, so the pattern came out empty and the caller's wrap made it match everything. Both now refuse: the transport raises, and the translator returns null so each of the four call sites emits a clause that matches nothing. After this, no entity can silently serve a full page for an input the database answers with an empty one: Collections and Publications because a guard routes it to the database, Proveniences because its clause now matches nothing, and Periods because no such input reaches the engine. Publications' from and to join its guard in the same commit, since they reach the body raw and would otherwise 500 on a value the database still answers.

Runbook

search_index_create --entity period, then search_index_swap --entity period --version 1 --alias both, then search_backfill --entity period, which needs php -d memory_limit=1G under the container's 128M default.

Two states to expect. Before the index exists, /periods is a 500 rather than a fallback: a missing index is a refused query, not an unreachable engine, and that is the same on all four entities as of this stack. Between the swap and the backfill the index exists and is empty, so the page renders zero periods with nothing logged; the third command fills it.

Evidence and timings are in comments.

Merge request reports

Loading
Loading