-
Notifications
You must be signed in to change notification settings - Fork 12
Add GIN expression index on boxel_index.types so type-anchored search filters are index-served #5533
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add GIN expression index on boxel_index.types so type-anchored search filters are index-served #5533
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| // GIN expression index for type-anchored search filters. The types-contains | ||
| // predicate in packages/runtime-common/expression.ts compiles to | ||
| // `COALESCE(types, '[]'::jsonb) @> '["<type>"]'::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 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 boxel_index_types_containment_idx | ||
| ON boxel_index | ||
| USING GIN ((COALESCE(types, '[]'::jsonb)) jsonb_path_ops); | ||
| `); | ||
|
Comment on lines
+35
to
+38
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] The open question for an expression index like this is whether the planner still reaches it inside the full query the engine emits — an isolated What the engine actually emits. A type filter never appears as a single containment predicate. Verified against a real indexed dataset (13.5k rows / 23 realms, Postgres 16.3), running the full emitted The same holds in the three variations that could have defeated it:
And the before-state confirms the premise for dropping the bare-column indexes: with only One boundary worth knowing: the index only helps where the containment sits in an AND chain over For background on why no query-side change is needed here (unlike the singular string |
||
| pgm.sql( | ||
| `DROP INDEX CONCURRENTLY IF EXISTS boxel_index_working_types_containment_idx;`, | ||
| ); | ||
| pgm.sql(` | ||
| CREATE INDEX CONCURRENTLY 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. Same | ||
| // drop-then-create pattern as up() to clear INVALID leftovers on retry. | ||
|
Comment on lines
+59
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Verified the restore fidelity: the referenced migrations create these via One wording nit while you're in here: "still find them" is relative-time phrasing — the comment reads cleaner as a timeless contract, e.g. "…so the down migrations of 1735668047598 and 1735832183444 find them by name." (Referencing migrations by timestamp is fine — they're immutable filenames in this directory, unlike tracker or PR numbers, which don't belong in comments.) |
||
| pgm.sql(`DROP INDEX CONCURRENTLY IF EXISTS boxel_index_types_index;`); | ||
| pgm.sql( | ||
| `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 boxel_index_working_types_index ON boxel_index_working USING GIN (types);`, | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
Comment on lines
+557
to
+561
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Good — this comment is the guard that keeps the two sides of the contract from drifting, since nothing executable enforces the coupling. One clarification worth folding in: "matches it verbatim" slightly overstates the strictness, and knowing where the real line sits helps a future editor judge what's safe. The planner matches on the parsed expression tree, not the SQL text. So these are all fine and don't break index use:
What does break it is any semantic change to the left operand's tree: a different empty-array placeholder ( Suggested tweak: "…Postgres only uses an expression index when the query's left operand structurally matches the indexed expression (aliasing and whitespace don't matter; any semantic change to the COALESCE wrapper does), so changing the SQL here un-indexes type filters unless the index expression moves with it." |
||
| return ['COALESCE(', column, `, '[]'::jsonb) @>`, param([key]), '::jsonb'] | ||
| .map(renderElement) | ||
| .join(' '); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖]
jsonb_path_opsis the right call for this predicate, and it's worth spelling out the two capabilities it trades away so a future change on this column knows when the choice stops fitting:@>. The key-exists operators (?,?|,?&) and everything else in the defaultjsonb_opsrepertoire are unsupported. Thetypes-containspredicate is pure containment, so nothing is lost — but a future filter wanting e.g. "does any type key start with…" or key-existence semantics would need ajsonb_opsindex (or a different structure entirely).typesis NULL index as'[]'under the COALESCE and therefore have no entries — which is exactly right: they can never satisfy a containment test against a non-empty right-hand side, and the engine always binds a one-element array ('["<key>"]'), never an empty one. The degenerate@> '[]'query (true for every row, answerable only by a full index scan) is not a shape the engine can emit.Nothing to change — this is confirmation that both trade-offs are safe for the emitted predicate, plus the conditions under which they'd stop being safe.