From b3001bf4cb9a9674b3eda55e9c31064b9867f16b Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 17 Jul 2026 14:15:01 +0700 Subject: [PATCH 1/2] Add GIN expression index on boxel_index.types for containment filters Type-anchored search filters compile to COALESCE(types, '[]'::jsonb) @> '[""]'::jsonb, which the planner can only serve from an index on that exact expression. The existing bare-column GIN indexes on types never matched it (and nothing else filters on types), so type filters seq-scanned. Replace them with jsonb_path_ops expression indexes on both boxel_index and boxel_index_working. Co-Authored-By: Claude Fable 5 --- .../config/schema/1783724082538_schema.sql | 235 ------------------ ...54_schema.sql => 1784272066344_schema.sql} | 0 ...84272066344_types-containment-gin-index.js | 53 ++++ 3 files changed, 53 insertions(+), 235 deletions(-) delete mode 100644 packages/host/config/schema/1783724082538_schema.sql rename packages/host/config/schema/{1783724976754_schema.sql => 1784272066344_schema.sql} (100%) create mode 100644 packages/postgres/migrations/1784272066344_types-containment-gin-index.js diff --git a/packages/host/config/schema/1783724082538_schema.sql b/packages/host/config/schema/1783724082538_schema.sql deleted file mode 100644 index 874977fae1c..00000000000 --- a/packages/host/config/schema/1783724082538_schema.sql +++ /dev/null @@ -1,235 +0,0 @@ --- This is auto-generated by packages/realm-server/scripts/convert-to-sqlite.ts --- Please don't directly modify this file - - CREATE TABLE IF NOT EXISTS bot_commands ( - id NOT NULL, - bot_id NOT NULL, - command TEXT NOT NULL, - command_filter BLOB NOT NULL, - created_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS bot_registrations ( - id NOT NULL, - username TEXT NOT NULL, - created_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS boxel_index ( - url TEXT NOT NULL, - file_alias TEXT NOT NULL, - type TEXT NOT NULL, - generation INTEGER NOT NULL, - realm_url TEXT NOT NULL, - pristine_doc BLOB, - search_doc BLOB, - error_doc BLOB, - deps BLOB DEFAULT '[]', - types BLOB, - isolated_html TEXT, - indexed_at, - is_deleted BOOLEAN, - last_modified, - embedded_html BLOB, - atom_html TEXT, - fitted_html BLOB, - display_names BLOB, - resource_created_at, - icon_html TEXT, - head_html TEXT, - has_error BOOLEAN DEFAULT false NOT NULL, - last_known_good_deps BLOB, - markdown TEXT, - diagnostics BLOB, - PRIMARY KEY ( url, realm_url, type ) -); - - CREATE TABLE IF NOT EXISTS boxel_index_working ( - url TEXT NOT NULL, - file_alias TEXT NOT NULL, - type TEXT NOT NULL, - generation INTEGER NOT NULL, - realm_url TEXT NOT NULL, - pristine_doc BLOB, - search_doc BLOB, - error_doc BLOB, - deps BLOB DEFAULT '[]', - types BLOB, - icon_html TEXT, - isolated_html TEXT, - indexed_at, - is_deleted BOOLEAN, - last_modified, - embedded_html BLOB, - atom_html TEXT, - fitted_html BLOB, - display_names BLOB, - resource_created_at, - head_html TEXT, - has_error BOOLEAN DEFAULT false NOT NULL, - last_known_good_deps BLOB, - markdown TEXT, - diagnostics BLOB, - job_id INTEGER, - PRIMARY KEY ( url, realm_url, type ) -); - - CREATE TABLE IF NOT EXISTS incoming_webhooks ( - id NOT NULL, - username TEXT NOT NULL, - webhook_path TEXT NOT NULL, - verification_type TEXT NOT NULL, - verification_config BLOB NOT NULL, - signing_secret TEXT NOT NULL, - created_at NOT NULL, - updated_at NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS module_transpile_cache ( - realm_url TEXT NOT NULL, - canonical_path TEXT NOT NULL, - body TEXT, - headers BLOB, - dependency_keys BLOB, - generation DEFAULT 0 NOT NULL, - created_at, - PRIMARY KEY ( realm_url, canonical_path ) -); - - CREATE TABLE IF NOT EXISTS modules ( - url TEXT NOT NULL, - cache_scope TEXT NOT NULL, - auth_user_id TEXT NOT NULL, - resolved_realm_url TEXT NOT NULL, - definitions BLOB, - deps BLOB, - error_doc BLOB, - created_at, - file_alias TEXT, - url_hash TEXT GENERATED ALWAYS AS (url) STORED NOT NULL, - diagnostics BLOB, - PRIMARY KEY ( url, cache_scope, auth_user_id ) -); - - CREATE TABLE IF NOT EXISTS prerendered_html ( - url TEXT NOT NULL, - file_alias TEXT NOT NULL, - realm_url TEXT NOT NULL, - type TEXT NOT NULL, - fitted_html BLOB, - embedded_html BLOB, - atom_html TEXT, - head_html TEXT, - isolated_html TEXT, - markdown TEXT, - deps BLOB, - last_known_good_deps BLOB, - generation INTEGER NOT NULL, - is_deleted BOOLEAN, - error_doc BLOB, - rendered_at, - diagnostics BLOB, - PRIMARY KEY ( url, realm_url, type ) -); - - CREATE TABLE IF NOT EXISTS prerendered_html_working ( - url TEXT NOT NULL, - file_alias TEXT NOT NULL, - realm_url TEXT NOT NULL, - type TEXT NOT NULL, - fitted_html BLOB, - embedded_html BLOB, - atom_html TEXT, - head_html TEXT, - isolated_html TEXT, - markdown TEXT, - deps BLOB, - last_known_good_deps BLOB, - generation INTEGER NOT NULL, - is_deleted BOOLEAN, - error_doc BLOB, - rendered_at, - job_id INTEGER, - diagnostics BLOB, - PRIMARY KEY ( url, realm_url, type ) -); - - CREATE TABLE IF NOT EXISTS realm_file_meta ( - realm_url TEXT NOT NULL, - file_path TEXT NOT NULL, - created_at INTEGER NOT NULL, - content_hash TEXT, - content_size INTEGER, - PRIMARY KEY ( realm_url, file_path ) -); - - CREATE TABLE IF NOT EXISTS realm_generations ( - realm_url TEXT NOT NULL, - current_generation INTEGER NOT NULL, - loader_epoch TEXT DEFAULT '0' NOT NULL, - PRIMARY KEY ( realm_url ) -); - - CREATE TABLE IF NOT EXISTS realm_meta ( - realm_url TEXT NOT NULL, - generation INTEGER NOT NULL, - value BLOB NOT NULL, - indexed_at, - PRIMARY KEY ( realm_url, generation ) -); - - CREATE TABLE IF NOT EXISTS realm_metadata ( - url TEXT NOT NULL, - show_as_catalog BOOLEAN, - publishable BOOLEAN, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - archived_at, - PRIMARY KEY ( url ) -); - - CREATE TABLE IF NOT EXISTS realm_registry ( - id DEFAULT (hex(randomblob(16))) NOT NULL, - url TEXT NOT NULL, - kind TEXT NOT NULL, - disk_id TEXT NOT NULL, - owner_username TEXT NOT NULL, - source_url TEXT, - last_published_at, - pinned BOOLEAN DEFAULT false NOT NULL, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS realm_user_permissions ( - realm_url TEXT NOT NULL, - username TEXT NOT NULL, - read BOOLEAN NOT NULL, - write BOOLEAN NOT NULL, - realm_owner BOOLEAN DEFAULT false NOT NULL, - PRIMARY KEY ( realm_url, username ) -); - - CREATE TABLE IF NOT EXISTS unlisted_realm_paths ( - id DEFAULT (hex(randomblob(16))) NOT NULL, - source_realm_url TEXT NOT NULL, - slug TEXT NOT NULL, - owner_user_id TEXT NOT NULL, - created_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at DEFAULT CURRENT_TIMESTAMP NOT NULL, - PRIMARY KEY ( id ) -); - - CREATE TABLE IF NOT EXISTS webhook_commands ( - id NOT NULL, - incoming_webhook_id NOT NULL, - command TEXT NOT NULL, - command_filter BLOB, - created_at NOT NULL, - updated_at NOT NULL, - PRIMARY KEY ( id ) -); \ No newline at end of file diff --git a/packages/host/config/schema/1783724976754_schema.sql b/packages/host/config/schema/1784272066344_schema.sql similarity index 100% rename from packages/host/config/schema/1783724976754_schema.sql rename to packages/host/config/schema/1784272066344_schema.sql diff --git a/packages/postgres/migrations/1784272066344_types-containment-gin-index.js b/packages/postgres/migrations/1784272066344_types-containment-gin-index.js new file mode 100644 index 00000000000..38d0b9601f3 --- /dev/null +++ b/packages/postgres/migrations/1784272066344_types-containment-gin-index.js @@ -0,0 +1,53 @@ +// GIN expression index for type-anchored search filters. The types-contains +// predicate in packages/runtime-common/expression.ts compiles to +// `COALESCE(types, '[]'::jsonb) @> '[""]'::jsonb`, and the planner +// only uses an index whose expression matches the query expression — the +// index must cover COALESCE(types, '[]'::jsonb), not the bare column. +// jsonb_path_ops is smaller and faster than the default jsonb_ops and +// supports @>, the only operator this predicate uses. +// +// The bare-column GIN indexes on types (from 1735668047598 and +// 1735832183444) can never serve this predicate, and no other query +// filters on types, so they are dropped here. +// +// `boxel_index_working` mirrors `boxel_index` because the search path +// can target either table via the useWorkInProgressIndex query option. +// +// CONCURRENTLY avoids locking writes during long builds in production. +// node-pg-migrate's outer singleTransaction wrapper is broken for this +// migration via pgm.noTransaction() — CREATE/DROP INDEX CONCURRENTLY +// cannot run inside a transaction at all. + +exports.up = (pgm) => { + pgm.noTransaction(); + pgm.sql(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS boxel_index_types_containment_idx + ON boxel_index + USING GIN ((COALESCE(types, '[]'::jsonb)) jsonb_path_ops); + `); + pgm.sql(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS boxel_index_working_types_containment_idx + ON boxel_index_working + USING GIN ((COALESCE(types, '[]'::jsonb)) jsonb_path_ops); + `); + pgm.sql(`DROP INDEX CONCURRENTLY IF EXISTS boxel_index_types_index;`); + pgm.sql(`DROP INDEX CONCURRENTLY IF EXISTS boxel_index_working_types_index;`); +}; + +exports.down = (pgm) => { + pgm.noTransaction(); + pgm.sql( + `DROP INDEX CONCURRENTLY IF EXISTS boxel_index_types_containment_idx;`, + ); + pgm.sql( + `DROP INDEX CONCURRENTLY IF EXISTS boxel_index_working_types_containment_idx;`, + ); + // Restore under the original auto-generated names so the down migrations + // of 1735668047598 and 1735832183444 still find them. + pgm.sql( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS boxel_index_types_index ON boxel_index USING GIN (types);`, + ); + pgm.sql( + `CREATE INDEX CONCURRENTLY IF NOT EXISTS boxel_index_working_types_index ON boxel_index_working USING GIN (types);`, + ); +}; From 127e57bf08ddd9fa01ae2e3539fadb20bd8a4324 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Fri, 17 Jul 2026 15:29:28 +0700 Subject: [PATCH 2/2] Make concurrent index builds self-healing on retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An interrupted CREATE INDEX CONCURRENTLY leaves an INVALID index that the planner ignores, and IF NOT EXISTS would skip it on the retry — the migration would then record success while delivering nothing. Drop the target name unconditionally before each build instead (safe: the migration only re-runs when a prior attempt failed to record), in both up() and the down() restores. Also note the expected node-pg-migrate break-single-transaction warning in the migration header, and cross-reference the index from the types-contains SQL in expression.ts, whose compiled expression must stay verbatim-identical to the index expression. Co-Authored-By: Claude Fable 5 --- ...84272066344_types-containment-gin-index.js | 33 ++++++++++++++----- packages/runtime-common/expression.ts | 5 +++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/postgres/migrations/1784272066344_types-containment-gin-index.js b/packages/postgres/migrations/1784272066344_types-containment-gin-index.js index 38d0b9601f3..8ce9bd1cb1f 100644 --- a/packages/postgres/migrations/1784272066344_types-containment-gin-index.js +++ b/packages/postgres/migrations/1784272066344_types-containment-gin-index.js @@ -14,19 +14,33 @@ // can target either table via the useWorkInProgressIndex query option. // // CONCURRENTLY avoids locking writes during long builds in production. -// node-pg-migrate's outer singleTransaction wrapper is broken for this -// migration via pgm.noTransaction() — CREATE/DROP INDEX CONCURRENTLY -// cannot run inside a transaction at all. +// node-pg-migrate's outer singleTransaction wrapper is opted out of via +// pgm.noTransaction() — CREATE/DROP INDEX CONCURRENTLY cannot run inside +// a transaction at all. node-pg-migrate logs +// `#> WARNING: Need to break single transaction! <` when applying this +// migration; that is expected, not a failure. +// +// An interrupted CONCURRENTLY build (e.g. a deploy restart mid-build) +// leaves an INVALID index under the target name, which the planner +// ignores. The migration only re-runs when a prior attempt failed to +// record, so each CREATE is preceded by an unconditional DROP to clear +// any such leftover and make retries self-healing. exports.up = (pgm) => { pgm.noTransaction(); + pgm.sql( + `DROP INDEX CONCURRENTLY IF EXISTS boxel_index_types_containment_idx;`, + ); pgm.sql(` - CREATE INDEX CONCURRENTLY IF NOT EXISTS boxel_index_types_containment_idx + CREATE INDEX CONCURRENTLY boxel_index_types_containment_idx ON boxel_index USING GIN ((COALESCE(types, '[]'::jsonb)) jsonb_path_ops); `); + pgm.sql( + `DROP INDEX CONCURRENTLY IF EXISTS boxel_index_working_types_containment_idx;`, + ); pgm.sql(` - CREATE INDEX CONCURRENTLY IF NOT EXISTS boxel_index_working_types_containment_idx + CREATE INDEX CONCURRENTLY boxel_index_working_types_containment_idx ON boxel_index_working USING GIN ((COALESCE(types, '[]'::jsonb)) jsonb_path_ops); `); @@ -43,11 +57,14 @@ exports.down = (pgm) => { `DROP INDEX CONCURRENTLY IF EXISTS boxel_index_working_types_containment_idx;`, ); // Restore under the original auto-generated names so the down migrations - // of 1735668047598 and 1735832183444 still find them. + // of 1735668047598 and 1735832183444 still find them. Same + // drop-then-create pattern as up() to clear INVALID leftovers on retry. + pgm.sql(`DROP INDEX CONCURRENTLY IF EXISTS boxel_index_types_index;`); pgm.sql( - `CREATE INDEX CONCURRENTLY IF NOT EXISTS boxel_index_types_index ON boxel_index USING GIN (types);`, + `CREATE INDEX CONCURRENTLY boxel_index_types_index ON boxel_index USING GIN (types);`, ); + pgm.sql(`DROP INDEX CONCURRENTLY IF EXISTS boxel_index_working_types_index;`); pgm.sql( - `CREATE INDEX CONCURRENTLY IF NOT EXISTS boxel_index_working_types_index ON boxel_index_working USING GIN (types);`, + `CREATE INDEX CONCURRENTLY boxel_index_working_types_index ON boxel_index_working USING GIN (types);`, ); }; diff --git a/packages/runtime-common/expression.ts b/packages/runtime-common/expression.ts index 9a2db4bb7c5..0b19ca9c843 100644 --- a/packages/runtime-common/expression.ts +++ b/packages/runtime-common/expression.ts @@ -554,6 +554,11 @@ export function expressionToSql( .map(renderElement) .join(' '); } + // The boxel_index_types_containment_idx GIN indexes (migration + // 1784272066344) cover this exact expression — Postgres only uses an + // expression index when the query expression matches it verbatim, so + // changing the SQL here (e.g. the COALESCE wrapper) un-indexes type + // filters unless the index expression moves with it. return ['COALESCE(', column, `, '[]'::jsonb) @>`, param([key]), '::jsonb'] .map(renderElement) .join(' ');