Files
Oxicloud/migrations/20260701000000_content_search_index.sql
Claude 8dab135090 Add embedded Tantivy full-text content search
/api/search now finds files by CONTENT as well as by name: BM25-ranked
matches over extracted text (PDF, Office OOXML/ODF, plain text/code)
with typo-tolerant fuzzy terms and search-as-you-type prefix matching,
served from an embedded Tantivy index at {storage}/.search-index.

Pipeline (all off the request path, mirroring tree-etag + thumbnails):
- statement triggers on storage.files append to a durable dirty queue
  (storage.search_index_dirty) - every write surface (REST, WebDAV,
  NextCloud, WOPI, trash) is covered, crash-safe by construction
- ContentIndexWorker drains the queue on the maintenance pool, extracts
  text once per unique BLAKE3 blob (storage.blob_extracted_text cache:
  N copies = 1 extraction, renames/moves = 0 re-extraction) and applies
  batched single-writer Tantivy commits; queue rows are deleted only
  after the commit succeeds (at-least-once, idempotent upserts)
- the index is a derived artifact: a version-marker mismatch wipes and
  reseeds it from Postgres, which remains the single source of truth

SearchService merges content hits into the existing name search: hits
are hydrated through ONE SQL round-trip that re-applies user scope,
trash state and every active filter (a stale index id can never leak),
scored below name matches, and returned with a plain-text snippet and
a match_source field. Index failure or
OXICLOUD_ENABLE_CONTENT_SEARCH=false degrades to name-only search; a
discard-only janitor keeps the trigger-fed queue bounded while disabled.

The frontend renders the snippet under the file name in list view.

New dependencies: tantivy 0.26, zip 8.6 (deflate only), pdf-extract 0.10.

https://claude.ai/code/session_01Sc7F4xbo83YbFAQ4xEeDrX
2026-06-11 15:16:03 +00:00

130 lines
6.7 KiB
PL/PgSQL

-- Content-search indexing pipeline: durable dirty-queue + per-blob text cache.
--
-- OxiCloud's search gains an embedded Tantivy (BM25) index over file NAMES and
-- file CONTENT (extracted text from PDFs, Office documents, plain text/code).
-- The Tantivy index lives on local disk ({storage}/.search-index) and is a
-- DERIVED artifact: PostgreSQL remains the single source of truth, and the
-- index can always be rebuilt from it (the app reseeds this queue whenever the
-- on-disk index is missing or its schema version changed).
--
-- This migration installs the PG side of the pipeline:
--
-- * `storage.search_index_dirty` — append-only dirty queue, mirroring the
-- proven `storage.tree_etag_dirty` design: statement-level triggers on
-- `storage.files` only INSERT queue rows (plain heap append, zero shared
-- row locks, no unique constraints), and a single background worker in the
-- app (`ContentIndexWorker`, on the maintenance pool) drains them and
-- applies the Tantivy mutations. Trigger-based capture means EVERY write
-- surface (REST, WebDAV, NextCloud, WOPI, batch ops, trash) is covered,
-- including paths that never call the in-process lifecycle hooks, and the
-- queue survives crashes/restarts (an in-memory channel would not).
--
-- * `storage.blob_extracted_text` — per-BLOB extraction cache. Text is
-- derived from CONTENT, and content is content-addressed (BLAKE3), so
-- extraction is keyed by `blob_hash`, not by file: N copies of the same
-- PDF cost ONE extraction, and rename/move/copy never re-extract.
-- No FK on blob_hash: a file's hash may resolve to either
-- `storage.blobs` (legacy whole blob) or `storage.chunk_manifests`
-- (CDC file hash); the worker garbage-collects orphans instead.
--
-- Queue semantics:
-- * Duplicates are expected and harmless — upserts are idempotent
-- (Tantivy delete_term + add_document) and the worker dedups per drain
-- batch keeping the latest op per file.
-- * The worker deletes queue rows ONLY AFTER the Tantivy commit succeeds,
-- so a crash between drain and commit re-processes the batch (at-least-
-- once delivery; idempotency makes that safe).
-- * When content search is disabled the app still drains the queue
-- (discard-only janitor) so it cannot grow unboundedly; re-enabling
-- triggers a full reseed via the index-version check anyway.
--
-- UPDATE value filter: only changes observable by the index enqueue —
-- a rename (name), a content swap (blob_hash) or a trash transition
-- (is_trashed). The EXIF `media_sort_date` sync and other metadata-only
-- updates never enqueue. Trashed files are removed from the index (they
-- must not appear in search) and re-added on restore.
-- ── Dirty queue ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS storage.search_index_dirty (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
file_id UUID NOT NULL,
op TEXT NOT NULL CHECK (op IN ('upsert', 'delete'))
);
-- ── Per-blob extracted text cache ────────────────────────────────────
-- `text` is NULL unless status = 'ok'. `status` is terminal per blob —
-- a failed/unsupported blob is never retried until an extractor-version
-- bump wipes the row (cheap: DELETE WHERE extractor <> current).
CREATE TABLE IF NOT EXISTS storage.blob_extracted_text (
blob_hash VARCHAR(64) PRIMARY KEY,
text TEXT,
status TEXT NOT NULL CHECK (status IN ('ok', 'empty', 'failed', 'too_large')),
extractor TEXT NOT NULL,
extracted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── File side: INSERT / DELETE ───────────────────────────────────────
-- Both triggers alias their transition table to `changed_rows`; one body
-- serves both events. TG_OP picks the queue op. INSERTs of already-trashed
-- rows (trash restores go through UPDATE) are skipped.
CREATE OR REPLACE FUNCTION storage.enqueue_search_from_files_stmt()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO storage.search_index_dirty (file_id, op)
SELECT id, 'upsert' FROM changed_rows WHERE NOT is_trashed;
ELSE
INSERT INTO storage.search_index_dirty (file_id, op)
SELECT id, 'delete' FROM changed_rows;
END IF;
RETURN NULL;
END;
$$;
-- ── File side: UPDATE ────────────────────────────────────────────────
-- Value filter: only rename / content swap / trash transitions enqueue.
-- (PostgreSQL forbids `AFTER UPDATE OF <cols>` with transition tables,
-- so the filter lives here.) A row trashed by the update enqueues a
-- 'delete'; everything else enqueues an 'upsert' re-index.
CREATE OR REPLACE FUNCTION storage.enqueue_search_from_files_stmt_upd()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO storage.search_index_dirty (file_id, op)
SELECT n.id,
CASE WHEN n.is_trashed THEN 'delete' ELSE 'upsert' END
FROM old_rows o
JOIN new_rows n USING (id)
WHERE (o.name, o.blob_hash, o.is_trashed)
IS DISTINCT FROM
(n.name, n.blob_hash, n.is_trashed);
RETURN NULL;
END;
$$;
-- PG 13 compatibility: DROP-then-CREATE (no CREATE OR REPLACE TRIGGER).
DROP TRIGGER IF EXISTS files_enqueue_search_ins ON storage.files;
CREATE TRIGGER files_enqueue_search_ins
AFTER INSERT ON storage.files
REFERENCING NEW TABLE AS changed_rows
FOR EACH STATEMENT EXECUTE FUNCTION storage.enqueue_search_from_files_stmt();
DROP TRIGGER IF EXISTS files_enqueue_search_del ON storage.files;
CREATE TRIGGER files_enqueue_search_del
AFTER DELETE ON storage.files
REFERENCING OLD TABLE AS changed_rows
FOR EACH STATEMENT EXECUTE FUNCTION storage.enqueue_search_from_files_stmt();
DROP TRIGGER IF EXISTS files_enqueue_search_upd ON storage.files;
CREATE TRIGGER files_enqueue_search_upd
AFTER UPDATE ON storage.files
REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows
FOR EACH STATEMENT EXECUTE FUNCTION storage.enqueue_search_from_files_stmt_upd();
-- ── Backfill ─────────────────────────────────────────────────────────
-- Existing deployments: queue every live file once so the first worker
-- run indexes the historical corpus. Fresh databases enqueue nothing.
INSERT INTO storage.search_index_dirty (file_id, op)
SELECT id, 'upsert' FROM storage.files WHERE NOT is_trashed;