Skip to content

feat: replace Elasticsearch with PostgreSQL full-text search (PPT-2644, 2/2) - #443

Draft
camreeves wants to merge 3 commits into
masterfrom
PPT-2644-pg-full-text-search
Draft

feat: replace Elasticsearch with PostgreSQL full-text search (PPT-2644, 2/2)#443
camreeves wants to merge 3 commits into
masterfrom
PPT-2644-pg-full-text-search

Conversation

@camreeves

@camreeves camreeves commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Part 2 of PPT-2644. Depends on PlaceOS/models#322 (the search_vector migration) being deployed; builds against models 9.x as-is.

What

Every index route (24 controllers) now queries PostgreSQL directly instead of Elasticsearch:

  • q is translated by Utils::TextSearch into a bound-parameter to_tsquery('simple', …) of AND-ed prefix tokens against the generated search_vector columns. The sanitizer never raises and degrades ES-era syntax (field prefixes, +|-"()*~, boolean words) into plain terms — pinned by spec vectors including Backoffice's tags:(+level AND +building) string.
  • Filters became SQL WHERE clauses with the same semantics they effectively had (multi-zone AND-containment, only-when-true booleans, etc.).
  • ES parent-child became EXISTS joins: 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).
  • Deterministic ordering everywhere (name, id default) — ES "relevance" order was already discarded by the id-rehydration path, and intra-page order was PG-arbitrary.
  • /reindex + /backfill are deprecated 200 no-ops (Backoffice's admin card calls them); search_ingest left the /cluster/versions fan-out; the search-ingest + direct neuroplastic deps are gone (neuroplastic remains transitively via models until the models major drops it).
  • Exact totals in X-Total-Count, and the Link header off-by-one that silently skipped one row per page (also affecting the 7 pre-existing SQL routes) is fixed. ref is accepted-and-ignored; fields likewise (it never was a projection).
  • Spec harness: elastic + search-ingest containers gone; ~40 refresh_elastic/sleep/poll sites now synchronous asserts.

Deliberate behavior fixes (each with a pinning spec)

  1. GET /systems?trigger_id= — never returned anything (ES query was unsatisfiable) → real join.
  2. GET /systems?email= — no-op alone; widened the group/subsystem authorization scope when combined → strict AND filter.
  3. Scoped (non-admin) GET /modules returned [] whenever the user could access >1 module → IN().
  4. Alerts multi-dashboard scoping matched nothing for authorities with >1 dashboard → IN().
  5. alerts?enabled=false, asset_categories?hidden=false were silently ignored → filter both values.
  6. 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

  • Secrets/encrypted content are no longer searchable (ES indexed password_digest, api-key secrets, unencrypted settings_string, …).
  • Multi-word q is AND-of-prefixes (ES OR-ed terms; autocomplete UIs already intersect client-side).
  • fields=name^2 boost syntax is ignored.
  • asciifolding (café→cafe) dropped ('simple' config, matching the guests precedent).

⚠ Before merge

Rollout: merge models#322 → 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

🤖 Generated with Claude Code

camreeves and others added 3 commits August 6, 2026 02:58
…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>
@github-actions github-actions Bot added the type: enhancement new feature or request label Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: enhancement new feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant