feat: replace Elasticsearch with PostgreSQL full-text search (PPT-2644, 2/2) - #445
Merged
Conversation
…on (PPT-2644) - Utils::TextSearch: never-500 q -> tsquery sanitizer (AND of prefix tokens) - application.cr: search_tsquery/search_limit/search_offset + paginate_search; fix the Link header off-by-one that skipped one row per page - modules: parent-driver search via EXISTS join (has_parent parity); scoped listing now uses IN() (the ES version returned nothing for >1 visible module) - zones: tree/tag filters in SQL, children counts via GROUP BY - drivers: straight conversion NOTE: pg-orm 2.2.x Collections cannot compile a single-argument raw where() (NamedTuple-in-union overload) — always pass bind args. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…PT-2644) - docker-compose: remove elastic + search-ingest services/anchors - spec/helper: remove refresh_elastic/ensure_keyword_mapping/clear_elastic - test_base_index macro: synchronous save->query->assert (no index lag) - root.cr: /reindex + /backfill deprecated no-ops; search_ingest out of the cluster version fan-out; require removed - shard.yml/lock: search-ingest dependency removed (neuroplastic remains transitively via placeos-models until the models major drops it) - spec/migration: TEMPORARY models branch pin (revert before merge) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2644) All 24 Elasticsearch-backed controllers now query PostgreSQL directly: filters become SQL WHERE clauses, q matches the generated search_vector columns, and ES parent-child becomes EXISTS joins (modules<->driver, system-triggers<->trigger, systems<->trigger instances, assets<->asset_type). paginate_results and the ES spec helpers are gone. Deliberate behavior fixes (each spec-pinned): - systems?trigger_id= worked never (unsatisfiable ES query) — real join - systems?email= was a no-op alone and WIDENED the group/subsystem zone scope when combined — now a strict AND filter - scoped (non-admin) module listing returned [] when the user could access more than one module — now IN() - alerts multi-dashboard scoping matched nothing for authorities with more than one dashboard — now IN() - alerts?enabled=false and asset_categories?hidden=false were ignored (falsy-Bool) — now filter both values - system-triggers q was silently ignored — now matches the parent trigger's text - assets can now be searched by asset-type name (previously a TODO) Known ES-era quirks preserved: zones/assets/pending_mails multi-zone AND-containment, public/important/triggered only-when-true filters, undecrypted settings search results. pg-orm gotchas encoded: never single-arg raw where (does not compile); arrays cannot bind as one parameter — sql_array() splices per-element placeholders; empty arrays guard with an empty-page short-circuit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… (PPT-2644) - settings_spec: find! instead of find.not_nil! (ameba) - OPENAPI_DOC.yml regenerated via --docs: ES simple-query-string links replaced with the PG prefix-search description, ref/fields marked deprecated-ignored - shard.yml: remove comments orphaned by the dep removal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rtance (PPT-2644) - asset_type_id / purchase_order_id are TEXT columns (prefixed ids), not bigint — the to_i64? guard short-circuited every filter to an empty page; plain equality now (unknown ids naturally match nothing) - system-triggers spec: set important/triggered via update — TriggerInstance's before_create overwrites important with the parent trigger's value - (short_url q now passes via the models-side placeos_fts_uri change: URI/path segments are individually prefix-searchable, as under ES) Host-side verification: system-triggers + assets + short_url specs 56 examples, 0 failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n master Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Greptile SummaryThe PR replaces Elasticsearch-backed index searches with PostgreSQL full-text queries and SQL relationship filters, while retaining deprecated indexing endpoints as compatibility no-ops.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains within the eligible follow-up-review scope. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| src/placeos-rest-api/utilities/text-search.cr | Introduces bounded Unicode tokenization and bound-parameter prefix tsquery construction. |
| src/placeos-rest-api/controllers/application.cr | Adds shared PostgreSQL search pagination and corrects the next-page offset calculation. |
| src/placeos-rest-api/controllers/systems.cr | Converts system search and filter composition to SQL, including relationship-based trigger filtering. |
| src/placeos-rest-api/controllers/modules.cr | Converts module search and scoped access filtering to SQL with driver relationship matching. |
| src/placeos-rest-api/controllers/assets.cr | Converts asset search and filters to SQL and adds asset-type relationship matching. |
| src/placeos-rest-api/controllers/root.cr | Retains reindex and backfill routes as deprecated compatibility no-ops. |
| shard.yml | Removes direct search-ingest and neuroplastic dependencies after the PostgreSQL search migration. |
| OPENAPI_DOC.yml | Regenerates API documentation for PostgreSQL search semantics, offset pagination, and deprecated indexing routes. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Client[API index request] --> Params[Parse q, filters, limit, offset]
Params --> Sanitize[Sanitize q into prefix tokens]
Sanitize --> TSQuery[Bound to_tsquery simple]
Params --> Scope[Apply authorization and SQL filters]
TSQuery --> Query[PostgreSQL model query]
Scope --> Query
Query --> Exists[Optional relationship EXISTS joins]
Exists --> Page[Deterministic ordering and pagination]
Page --> Response[JSON results plus count and Link headers]
Reviews (4): Last reviewed commit: "chore: track released placeos-models 9.1..." | Re-trigger Greptile
…ba shadowing The zone-permissions metadata doc is what grants the management group its scoped access; a spec clearing Metadata under an unlucky seed removed it while the zone survived, making modules_spec's scoped count 0 (seen on CI seed 95267). Re-ensure the doc independently of zone creation. Also rename the two block parameters Ameba flagged as shadowing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…isibility Model::Generator.module rolls a uniformly random driver role. Two of the new specs asserted counts through paths where a Logic roll changes the result legitimately: the management whole-list deliberately excludes logic modules (role <> 99 in the zone-scope CTE), and a logic module gets a home system that then matches ?module_id= as an extra row. Each failed ~1 run in 5, seed-independent — diagnosed by instrumenting a failing run and finding every fixture ingredient correct with the response empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Depends on the models
search_vectormigration (models branchPPT-2644-pg-full-text-search, migration20260810100500000) being deployed; builds against models 9.x as-is.(2026-08-10: rebased onto current master — zero conflicts with the PPT-2000/2032 consent work and #444 — and
OPENAPI_DOC.ymlregenerated to pick up the new admin-consent flow endpoint master had added without regenerating.)What
Every index route (24 controllers) now queries PostgreSQL directly instead of Elasticsearch:
qis translated byUtils::TextSearchinto a bound-parameterto_tsquery('simple', …)of AND-ed prefix tokens against the generatedsearch_vectorcolumns. The sanitizer never raises and degrades ES-era syntax (field prefixes,+|-"()*~, boolean words) into plain terms — pinned by spec vectors including Backoffice'stags:(+level AND +building)string.WHEREclauses with the same semantics they effectively had (multi-zone AND-containment, only-when-true booleans, etc.).EXISTSjoins: modules↔driver (search modules by driver name — instantly consistent on driver rename, unlike the stale-copy ES join docs), system-triggers↔trigger, systems↔trigger-instances, and assets↔asset-type (previously a commented-out TODO, now implemented).name, iddefault) — ES "relevance" order was already discarded by the id-rehydration path, and intra-page order was PG-arbitrary./reindex+/backfillare deprecated 200 no-ops (Backoffice's admin card calls them);search_ingestleft the/cluster/versionsfan-out; thesearch-ingest+ directneuroplasticdeps are gone (neuroplastic remains transitively via models until the models major drops it).X-Total-Count, and theLinkheader off-by-one that silently skipped one row per page (also affecting the 7 pre-existing SQL routes) is fixed.refis accepted-and-ignored;fieldslikewise (it never was a projection).refresh_elastic/sleep/poll sites now synchronous asserts.OPENAPI_DOC.ymlregenerated — zero ES references.Deliberate behavior fixes (each with a pinning spec)
GET /systems?trigger_id=— never returned anything (ES query was unsatisfiable) → real join.GET /systems?email=— no-op alone; widened the group/subsystem authorization scope when combined → strict AND filter.GET /modulesreturned[]whenever the user could access >1 module →IN().IN().alerts?enabled=false,asset_categories?hidden=falsewere silently ignored → filter both values.GET /systems/:id/triggers?q=was silently ignored → matches parent trigger text.New coverage for previously untested surface: authentications (×3, no spec file existed), domains index, schema, webrtc
/rooms, short_url index, and the systems filter matrix (capacity/bookable/features/public/signage/zone_id/email/trigger_id).Deliberate divergences
password_digest, api-key secrets, unencryptedsettings_string, …).qis AND-of-prefixes (ES OR-ed terms; autocomplete UIs already intersect client-side).fields=name^2boost syntax is ignored.Test evidence (2026-08-10, local, post-rebase)
Full containerised suite (incl. core + build_service containers): 876 examples, 0 failures, 1 error, 1 pending. The pending is the pre-existing
Edges::ConnectionManagerskip. The single error isshort_url_spec:6— a pre-existing models defect, not this branch:Shortener#short_idallows only 63 distinct ids per wall-clock second ((now << 6) + rand(63)), so two same-second creates collide at ~1/63; standalone re-runs green. Worth its own one-line follow-up in models. All 4 specs that need core/build_service (websocket exec/debug, public_events driver call, build_monitor, root cluster-versions) passed.⚠ Before merge
— reverted inspec/migration/Dockerfilepinned to the models branch41867ad; the lock now tracks released models 9.107.1 (migration fix(edge/connection): refactor how connections are handled #324 + Shortener fix fix(webhook): resolve 404 on friendly webhook URI #325, both merged).Rollout: merge models migration → this → one release with ES still deployed-but-unread (rollback = previous image) → then infra removal (branches prepared: local, k8s-helm, PlaceOS meta, init, user-interfaces e2e, backoffice, ts-client, docs).
Full plan + research: https://gist.github.com/camreeves/4b3cb2cffd2544e894ec34dcdae799a6
CI flake ledger (pre-existing, not from this PR)
For reviewers triaging CI: the suite runs with randomized order, and these master specs are order/timing/randomness-sensitive independent of this change — each was diagnosed during this PR's verification (details in the branch history):
api_key_spec"rejects expired API keys" — 1s expiry vs 1.5s sleep, thin margin on a loaded runner.websocket/session_spec"debug"/"ignore" — core round-trip race (test(websocket): gate the bind/debug/ignore specs on the response arriving #444 reduced but did not eliminate it).uploads_spec:363— creates a default Storage where some orders leave one behind (authority_id need to be unique).— fix(webhook): resolve 404 on friendly webhook URI #325 merged; the lock update inshort_url_specShortener collision41867adresolves this one.playlist_spec/user_spec(models repo) — documented order/lock flakes, reproduce on master.Two genuinely new flaky specs introduced by this PR were found and fixed in-branch (
Generator.modulerolls a random driver role; specs asserting scoped/filtered visibility now pin a non-logic role).