Module: Hecks::Adapters::PostgresEra::Lineage::FieldCache
- Included in:
- Hecks::Adapters::PostgresEra::Lineage
- Defined in:
- lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb
Overview
THE READ-SIDE OF THE ERA WORKAROUND. Once a domain mints a
second era, head_view is a DISTINCT ON/UNION ALL
reduction — and a where clause on anything but id (its own
partition key) cannot be pushed through that reduction, so every
declared query pays the cost of reducing the WHOLE aggregate
before it can filter anything (see docs/implemented/postgres-era-adapter-
split-plan.md's own trigger section — this is a real
SQL-semantics wall, not a missing index; adding one to
head_view itself changes nothing, because the planner cannot
prove a predicate on a non-partition column commutes with
DISTINCT ON without risking a stale row winning).
THE WORKAROUND: one narrow table per declared where-field
(never order_by-only fields — sorting the reduced output was
never blocked by the reduction; only filtering was), holding
exactly (id, ordinal, value) — the CURRENT extracted value for
every live id, maintained transactionally by append alongside
the head snapshot it already upserts (same ordinal-guard idiom:
WHERE ordinal < EXCLUDED.ordinal). A query whose sole where
is on a cached field never runs the reduction at all: SELECT id FROM <field>_cache WHERE value <op> $1, then SELECT id, state FROM head_view WHERE id = ANY($ids) — safe THROUGH the
reduction because id is its own partition key, unlike the
original field.
SCOPED TO NON-LIST FIELDS ONLY, same reasoning Track A (plain
Postgres) and Track B (Sqlite/D1) both landed on independently
for their own automatic indexing: this codebase's contains
compiles to element-membership (jsonb_array_elements/
json_each + EXISTS), which a (id, ordinal, value) cache table
keyed on ONE scalar value per id has no way to represent — a
list field would need one row per (id, element), a different
shape this plan does not build. PostgresEra#eligible_for_cache?
is where that boundary lives.
Instance Method Summary collapse
- #backfill_field_cache!(name, storage_name, era, value_expression) ⇒ Object
- #delete_field_cache_row!(name, id) ⇒ Object
-
#ensure_field_cache!(storage_name, era, field, value_expression) ⇒ Object
Self-healing, same idiom as
ensure_head_snapshot!: cheap, unconditional, safe to call on every boot. -
#field_cache(storage_name, era, field) ⇒ Object
Deterministic and collision-free regardless of field-path length or storage-name length — Postgres identifiers cap at 63 bytes, and a value-object member path (
compliance.review. deadline_at) combined with a long storage name could exceed that if built any less defensively. -
#upsert_field_cache_row!(name, id, ordinal, state_json, value_expression) ⇒ Object
THE LIVE-WRITE SIDE — called from
append, inside the SAME transaction as the journal insert and the head-snapshot upsert, for every field this aggregate has a cache table for.
Instance Method Details
#backfill_field_cache!(name, storage_name, era, value_expression) ⇒ Object
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb', line 86 def backfill_field_cache!(name, storage_name, era, value_expression) chunked_backfill!( name, source_sql: lambda do |cursor| <<~SQL SELECT id, ordinal, value FROM (#{field_cache_source_sql(storage_name, era, value_expression)}) reduced #{cursor ? "WHERE id > #{text_literal(cursor)}" : ''} ORDER BY id LIMIT #{ResumableBackfill::CHUNK_SIZE} SQL end, upsert: lambda do |rows| upsert_field_cache_rows!(name, rows) end ) end |
#delete_field_cache_row!(name, id) ⇒ Object
122 123 124 |
# File 'lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb', line 122 def delete_field_cache_row!(name, id) @db.exec_params("DELETE FROM #{quote(name)} WHERE id = $1", [id]) end |
#ensure_field_cache!(storage_name, era, field, value_expression) ⇒ Object
Self-healing, same idiom as ensure_head_snapshot!: cheap,
unconditional, safe to call on every boot. CREATION is a
short-held lock (table doesn't exist yet — nothing to block);
BACKFILL runs outside it, chunk by chunk, each chunk its own
short lock — never one transaction spanning the scan, per
principle 1. value_expression is the exact SQL
PostgresEra#query_expression(field) already compiles for
this field, applied against a state column this module's own
SQL always makes available in scope (see field_cache_source_sql)
— ONE source of truth for "what does this field mean", not a
second hand-rolled copy that could silently drift from what a
live query actually filters on.
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb', line 65 def ensure_field_cache!(storage_name, era, field, value_expression) name = field_cache(storage_name, era, field) unless table_exists?(name) nested_transaction("hecks_field_cache_create") do @db.exec_params("SELECT pg_advisory_xact_lock(hashtext('hecks_field_cache:' || $1))", [name]) next if table_exists?(name) @db.exec(<<~SQL) CREATE TABLE #{quote(name)} ( id text PRIMARY KEY, ordinal bigint NOT NULL, value text ) SQL @db.exec("CREATE INDEX IF NOT EXISTS #{quote("#{name}_value_idx")} ON #{quote(name)} (value)") end end backfill_field_cache!(name, storage_name, era, value_expression) name end |
#field_cache(storage_name, era, field) ⇒ Object
Deterministic and collision-free regardless of field-path
length or storage-name length — Postgres identifiers cap at 63
bytes, and a value-object member path (compliance.review. deadline_at) combined with a long storage name could exceed
that if built any less defensively. Not meant to be
human-readable; hecks_backfill_progress/catalog lookups are
always driven by this same computed name, never typed by hand.
49 50 51 |
# File 'lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb', line 49 def field_cache(storage_name, era, field) "hecks_fc_#{Digest::SHA256.hexdigest("#{storage_name}\0#{era}\0#{field}")[0, 20]}" end |
#upsert_field_cache_row!(name, id, ordinal, state_json, value_expression) ⇒ Object
THE LIVE-WRITE SIDE — called from append, inside the SAME
transaction as the journal insert and the head-snapshot
upsert, for every field this aggregate has a cache table for.
state_json is the entry's OWN new state (already the thing
append is about to write) — bound as a literal jsonb value
and aliased state, so value_expression (built to read a
column literally named state) evaluates identically here and
in every backfill/query-time use, with no separate Ruby-side
re-implementation of "how to pick a value-object's member" to
keep in sync.
112 113 114 115 116 117 118 119 120 |
# File 'lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb', line 112 def upsert_field_cache_row!(name, id, ordinal, state_json, value_expression) @db.exec_params(<<~SQL, [id, ordinal, state_json]) INSERT INTO #{quote(name)} (id, ordinal, value) SELECT $1, $2, #{value_expression} FROM (SELECT $3::jsonb AS state) src ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, value = EXCLUDED.value WHERE #{quote(name)}.ordinal < EXCLUDED.ordinal SQL end |