Skip to content

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

Merged
stakach merged 9 commits into
masterfrom
PPT-2644-pg-full-text-search
Aug 12, 2026
Merged

feat: replace Elasticsearch with PostgreSQL full-text search (PPT-2644, 2/2)#445
stakach merged 9 commits into
masterfrom
PPT-2644-pg-full-text-search

Conversation

@camreeves

@camreeves camreeves commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Depends on the models search_vector migration (models branch PPT-2644-pg-full-text-search, migration 20260810100500000) 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.yml regenerated 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:

  • 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.
  • OPENAPI_DOC.yml regenerated — zero ES references.

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).

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::ConnectionManager skip. The single error is short_url_spec:6 — a pre-existing models defect, not this branch: Shortener#short_id allows 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

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):

Two genuinely new flaky specs introduced by this PR were found and fixed in-branch (Generator.module rolls a random driver role; specs asserting scoped/filtered visibility now pin a non-logic role).

camreeves and others added 6 commits August 10, 2026 21:44
…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>
@github-actions github-actions Bot added the type: enhancement new feature or request label Aug 11, 2026
@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown

Greptile Summary

The PR replaces Elasticsearch-backed index searches with PostgreSQL full-text queries and SQL relationship filters, while retaining deprecated indexing endpoints as compatibility no-ops.

  • Adds shared text-query sanitization and SQL pagination helpers.
  • Converts controller filtering, authorization scoping, and relationship searches to PostgreSQL queries.
  • Removes direct Elasticsearch/search-ingest dependencies and updates the test harness.
  • Regenerates the OpenAPI document for the revised search and pagination contracts.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up-review scope.

No blocking failure remains.

Important Files Changed

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]
Loading

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>
@github-actions github-actions Bot added type: enhancement new feature or request and removed type: enhancement new feature or request labels Aug 11, 2026
…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>
@github-actions github-actions Bot added type: enhancement new feature or request and removed type: enhancement new feature or request labels Aug 11, 2026
…ranch pin

The search_vector migration (#324) and the Shortener id-collision retry
(#325) are on models master now — the spec-harness clone pin is no longer
needed and the lock picks up both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added type: enhancement new feature or request and removed type: enhancement new feature or request labels Aug 11, 2026
@stakach
stakach merged commit 51faa1a into master Aug 12, 2026
12 checks passed
@stakach
stakach deleted the PPT-2644-pg-full-text-search branch August 12, 2026 01:48
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.

2 participants