Class: Hecks::Adapters::PostgresEra
- Inherits:
-
Object
- Object
- Hecks::Adapters::PostgresEra
- Includes:
- SqlQueryBuilder
- Defined in:
- lib/hecks/adapters/driven/postgres_era.rb,
lib/hecks/adapters/driven/postgres_era/lineage.rb,
lib/hecks/adapters/driven/postgres_era/lineage_manager.rb,
lib/hecks/adapters/driven/postgres_era/lineage/era_store.rb,
lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb,
lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb,
lib/hecks/adapters/driven/postgres_era/lineage/provisioning.rb,
lib/hecks/adapters/driven/postgres_era/lineage/head_compiler.rb,
lib/hecks/adapters/driven/postgres_era/lineage_manager/minter.rb,
lib/hecks/adapters/driven/postgres_era/lineage/mint_transaction.rb,
lib/hecks/adapters/driven/postgres_era/lineage/resumable_backfill.rb,
lib/hecks/adapters/driven/postgres_era/lineage/transform_installer.rb,
lib/hecks/adapters/driven/postgres_era/lineage_manager/era_resolver.rb,
lib/hecks/adapters/driven/postgres_era/lineage_manager/coverage_check.rb,
lib/hecks/adapters/driven/postgres_era/lineage_manager/merge_coordinator.rb
Overview
The enforcement-grade persistence adapter — and the only one that
declares the LINEAGE capability: it may act on shape drift
(translate, fork, merge) where every other adapter can only refuse
toward it. Sibling to the plain Postgres adapter (postgres.rb),
which is the same database with none of this machinery — pick
PostgresEra only once a domain actually needs to survive a shape
change live. See docs/implemented/postgres-era-adapter-split-plan.md for why
the two are split and what each one carries.
Storage model (see postgres_era/lineage.rb for the DDL):
- One journal per DOMAIN, list-partitioned by era, one ordinal sequence spanning partitions. Appends go there; nothing updates or deletes a journal row (immutability by privilege — UPDATE and DELETE revoked; a deployment's app role connects as a non-owner).
- Per aggregate, the HEAD is derived: era 1 reads a plain view
(latest save per id); later eras read a view overlaying the
materialized, translated ancestor tail with live current-era
rows.
projectis therefore a no-op — old entries are never rewritten, and the head is never a table anything writes. - State is ONE jsonb column. jsonb normalizes key order (and drops
duplicate keys), so anything comparing stored state — the corpus
history gate above all — must compare CANONICALIZED state, never
raw bytes;
bin/canonicalisedeep-sorts keys, which is exactly why the gate survives this normalization. - Query pushdown is the shared SqlQueryBuilder: every declared operator compiles fully into SQL, or the query refuses loudly.
Defined Under Namespace
Modules: LineageManager Classes: Lineage
Constant Summary
Constants included from SqlQueryBuilder
Instance Attribute Summary collapse
-
#aggregate ⇒ Object
readonly
Returns the value of attribute aggregate.
Class Method Summary collapse
- .connect_for(name, settings) ⇒ Object
- .era_check!(registry:, bluebook:, current_text:, settings:, directory: nil) ⇒ Object
-
.lineage_capable? ⇒ Boolean
The capability idiom: only PostgresEra answers true, and only PostgresEra carries an era_check! for the boot gate to delegate to.
-
.tenant_capable? ⇒ Boolean
TENANT-CAPABLE — see Runtime::TenantCheck's own header for the full reasoning.
Instance Method Summary collapse
-
#all(order_by: nil, direction: :asc) ⇒ Object
order_by IS A RUNTIME VALUE, not framework-authored bluebook source like every other caller of order_expression — a query param off an HTTP request, in the console's case.
-
#append(entry) ⇒ Object
HELD FOR THE WHOLE TRANSACTION, not just around the INSERT — the ordinal is assigned by the column's own
nextval()default, inside this same statement, so the lock has to already be held before that default evaluates. -
#atomic_put(entry, insert_only: false) ⇒ Object
Outcome detection, journal append and every derived projection share the SAME transaction and domain write lock.
- #count ⇒ Object
- #delete(id) ⇒ Object
- #delete_saga(process_manager:, correlation:) ⇒ Object
- #each_saga ⇒ Object
- #entries ⇒ Object
- #events ⇒ Object
- #find(id) ⇒ Object
-
#initialize(aggregate:, settings: {}, root: nil) ⇒ PostgresEra
constructor
A new instance of PostgresEra.
- #persistence_capabilities ⇒ Object
-
#project(entry) ⇒ Object
The head is DERIVED — projecting is reading, so there is nothing to write here.
-
#query(declared, args = {}, context: {}) ⇒ Object
THE TWO-PHASE SHORTCUT (Track C, docs/implemented/postgres-era-adapter- split-plan.md §3).
- #record_event(event) ⇒ Object
- #reset! ⇒ Object
- #save(instance) ⇒ Object
-
#save_saga(process_manager:, correlation:, state:, memory:) ⇒ Object
── the OPTIONAL saga-persistence capability (Ports::Persistence's own three-method shape, §2) — one row per (domain, process_manager, correlation),
domainkept as an explicit column even under schema isolation so two domains sharing one schema (neither declares its ownschema) still isolate correctly, matchinghecks_eras' own precedent (postgres/lineage/provisioning.rb). - #table ⇒ Object
Constructor Details
#initialize(aggregate:, settings: {}, root: nil) ⇒ PostgresEra
Returns a new instance of PostgresEra.
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 129 def initialize(aggregate:, settings: {}, root: nil) @aggregate = aggregate @db = self.class.connect_for(aggregate.name, settings) # The domain names the journal (one journal per lineage). The # factory injects it; a directly-instantiated adapter (specs, # consoles) journals under the aggregate's own name. @domain = (settings[:domain] || settings["domain"] || aggregate.storage_name).to_s @lineage = Lineage.new(@db, @domain) @lineage.ensure_base! # The era gate resolves which era this boot IS (an old checkout # boots a held-but-superseded era and keeps writing its own # partition); a directly-instantiated adapter defaults to the # newest. @era = settings[:era] || settings["era"] || @lineage.current_era # Unconditional and idempotent, regardless of era — belt-and- # suspenders self-healing (compile_head! already ensures this for # a freshly-minted era's own name; ensure_first_head! for era 1's) # against any boot-ordering surprise, at the cost of one # CREATE TABLE IF NOT EXISTS nobody pays for twice. @lineage.ensure_head_snapshot!(table, @era) @lineage.ensure_first_head!(table) if @era == 1 # THE READ-CACHE SIDE OF THE ERA WORKAROUND (Track C, # docs/implemented/postgres-era-adapter-split-plan.md §3) — one row-cache # table per `where`-field this aggregate's own declared queries # (and its entities' own) actually use, derived automatically # (principle 3 — no bluebook keyword), self-healing and # idempotent like everything else booted here. `@field_caches` # maps field -> cache-table name; `query` below consults it to # decide whether a declared query can skip the DISTINCT ON # reduction entirely. @field_caches = ensure_field_caches! create_event_table! create_saga_table! end |
Instance Attribute Details
#aggregate ⇒ Object (readonly)
Returns the value of attribute aggregate.
44 45 46 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 44 def aggregate @aggregate end |
Class Method Details
.connect_for(name, settings) ⇒ Object
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 70 def self.connect_for(name, settings) # LAZY, ON PURPOSE — same reasoning as Sqlite's own initialize: # a domain that never wires PostgresEra should never need the gem. require "pg" declared = settings[:database] || settings["database"] if declared.to_s.empty? raise Runtime::WiringError, "#{name} binds PostgresEra, which needs a database connection, " \ "but its world declares no \"database\"." end connection = if declared.start_with?("postgres://", "postgresql://") PG.connect(declared) else PG.connect(dbname: declared) end # SHARED-INSTANCE ISOLATION. A domain that declares `schema` is # sharing its Postgres instance with other domains (the # storehouse) — every unqualified table/view/function reference # this adapter and its lineage classes ever construct resolves # through search_path, so this one SET is what makes ALTER # TABLE ... SET SCHEMA migrations transparent to the rest of the # adapter. A domain with no `schema` setting keeps Postgres's # own default search_path (public), same as before this existed. schema = settings[:schema] || settings["schema"] if schema.to_s != "" # THE SCHEMA ITSELF, IDEMPOTENTLY — a domain naming a `schema:` # nobody has created yet used to fail on its FIRST table- # creation attempt with Postgres's own "no schema has been # selected to create in", found live provisioning tenant_ # isolation_spec.rb's own multi-schema fixture by hand before # this existed. `CREATE SCHEMA IF NOT EXISTS` is exactly the # same self-healing idempotency this adapter's own table/era # provisioning already holds itself to (see the comment right # below on `client_min_messages`) — a schema that already # exists is the ORDINARY case for every boot after the first, # not news. connection.exec("CREATE SCHEMA IF NOT EXISTS #{connection.quote_ident(schema)}") connection.exec("SET search_path TO #{connection.quote_ident(schema)}") end # QUIET ON PURPOSE. Provisioning re-runs its own idempotent # `CREATE ... IF NOT EXISTS` checks on every boot — a schema that # already exists is the ORDINARY case, not news, and Postgres # surfaces every one as a NOTICE by default. `bin/set-password` # boots a real registry just to mint an Identity, and nobody # setting a password needs to see a page of "relation ... # already exists, skipping" to do it. WARNING and above (real # problems) still surface. connection.exec("SET client_min_messages = warning") connection rescue PG::Error => error raise Runtime::WiringError, "cannot bind PostgresEra at #{declared} for #{name}: #{error..strip}" end |
.era_check!(registry:, bluebook:, current_text:, settings:, directory: nil) ⇒ Object
63 64 65 66 67 68 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 63 def self.era_check!(registry:, bluebook:, current_text:, settings:, directory: nil) LineageManager.check!( registry: registry, bluebook: bluebook, current_text: current_text, settings: settings, directory: directory ) end |
.lineage_capable? ⇒ Boolean
The capability idiom: only PostgresEra answers true, and only PostgresEra carries an era_check! for the boot gate to delegate to.
50 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 50 def self.lineage_capable? = true |
.tenant_capable? ⇒ Boolean
TENANT-CAPABLE — see Runtime::TenantCheck's own header for the
full reasoning. connect_for's own schema: setting (the
Storehouse shared-instance mechanism, already built, already
proven for eras) is what keeps two tenant boots' tables apart:
each boot's own SET search_path means every unqualified
reference this adapter and its lineage classes construct
resolves into that boot's own schema, never another tenant's —
proven for real, not assumed, by tenant_isolation_spec.rb, the
same discipline lineage_capable? already holds itself to.
61 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 61 def self.tenant_capable? = true |
Instance Method Details
#all(order_by: nil, direction: :asc) ⇒ Object
order_by IS A RUNTIME VALUE, not framework-authored bluebook source like every other caller of order_expression — a query param off an HTTP request, in the console's case. Whitelisted against the aggregate's own real attributes (plus its lifecycle field) before it ever reaches order_expression, unlike a declared query's order_by, which the language itself already only lets name a real attribute at parse time. Without this, an unknown field wouldn't error — query_expression degrades a nil attribute to a harmless no-op path — it would just silently sort by nothing.
182 183 184 185 186 187 188 189 190 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 182 def all(order_by: nil, direction: :asc) return @db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY id)).map { |row| instance(row) } unless order_by name = order_by.to_s.split(".").first raise Runtime::WiringError, "#{@aggregate.name} has no attribute #{order_by.inspect} to order by" unless @aggregate.lifecycle&.field.to_s == name || @aggregate.attribute(name) spec = QuerySpecification::Common::OrderBy.new(field: order_by, direction: direction) @db.exec(%(SELECT id, state FROM #{quoted_head} ORDER BY #{order_clause(spec, nil)})).map { |row| instance(row) } end |
#append(entry) ⇒ Object
HELD FOR THE WHOLE TRANSACTION, not just around the INSERT — the
ordinal is assigned by the column's own nextval() default, inside
this same statement, so the lock has to already be held before that
default evaluates. A DIFFERENT key from mint_era!/merge_tail!'s
hecks_eras:domain : this serializes plain writes against EACH
OTHER, never against a mint. See postgres/lineage.rb's own comment
for why only that half of the race is closed.
The journal insert and the snapshot upsert/delete happen in the
SAME transaction — real ACID atomicity, not the append-then-
project two-step a file-based adapter needs a crash-recovery
replay for (see Heki). If this transaction commits, the snapshot
is already exactly as current as the journal; if it doesn't,
neither happened. project stays uninvolved on purpose — it
still runs, cheaply, during AppendOnly#recover!'s full replay on
every boot (see project below), and a second write there would
make that replay pay real DB cost for a snapshot that's already
correct.
255 256 257 258 259 260 261 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 255 def append(entry) @db.transaction do lock_writes! append_and_project!(entry) end entry end |
#atomic_put(entry, insert_only: false) ⇒ Object
Outcome detection, journal append and every derived projection share
the SAME transaction and domain write lock. The lineage-aware head
determines whether this id is already visible; no repository find
occurs before entering this adapter-native operation.
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 267 def atomic_put(entry, insert_only: false) status = nil @db.transaction do lock_writes! exists = !@db.exec_params( "SELECT 1 FROM #{quoted_head} WHERE id = $1 LIMIT 1", [entry.id.to_s] ).ntuples.zero? if insert_only && exists status = :conflicted next end status = exists ? :replaced : :inserted append_and_project!(entry) end status end |
#count ⇒ Object
192 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 192 def count = @db.exec(%(SELECT COUNT(*) FROM #{quoted_head}))[0]["count"].to_i |
#delete(id) ⇒ Object
323 324 325 326 327 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 323 def delete(id) entry = Ports::Persistence::Entry.new(operation: "delete", id: id.to_s, state: nil) append(entry) true end |
#delete_saga(process_manager:, correlation:) ⇒ Object
368 369 370 371 372 373 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 368 def delete_saga(process_manager:, correlation:) @db.exec_params( "DELETE FROM hecks_saga_instances WHERE domain = $1 AND process_manager = $2 AND correlation = $3", [@domain, process_manager.to_s, correlation.to_s] ) end |
#each_saga ⇒ Object
375 376 377 378 379 380 381 382 383 384 385 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 375 def each_saga return enum_for(:each_saga) unless block_given? @db.exec_params( "SELECT process_manager, correlation, state, memory FROM hecks_saga_instances WHERE domain = $1", [@domain] ).each do |row| yield row["process_manager"], row["correlation"], row["state"], JSON.parse(row["memory"], symbolize_names: true) end end |
#entries ⇒ Object
296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 296 def entries @db.exec_params( "SELECT aggregate_id, operation, state, mirrors FROM #{@lineage.quoted_journal} " \ "WHERE aggregate = $1 ORDER BY ordinal", [table] ).map do |row| state = row["state"] && JSON.parse(row["state"]) Ports::Persistence::Entry.new( operation: row["operation"] || "save", id: row["aggregate_id"], state: state&.transform_keys(&:to_sym), mirrors: row["mirrors"] && JSON.parse(row["mirrors"]) ) end end |
#events ⇒ Object
336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 336 def events @db.exec("SELECT * FROM events ORDER BY id").map do |row| Runtime::Event.new( name: row["name"], aggregate: row["aggregate"], id: row["aggregate_id"], payload: JSON.parse(row["payload"], symbolize_names: true), occurred_at: row["occurred_at"] ) end end |
#find(id) ⇒ Object
166 167 168 169 170 171 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 166 def find(id) result = @db.exec_params(%(SELECT id, state FROM #{quoted_head} WHERE id = $1), [id.to_s]) return nil if result.ntuples.zero? instance(result[0]) end |
#persistence_capabilities ⇒ Object
46 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 46 def persistence_capabilities = [:atomic_put] |
#project(entry) ⇒ Object
The head is DERIVED — projecting is reading, so there is nothing
to write here. append above already keeps the snapshot the head
view reads from current, transactionally. The instance is still
built (and validated) so a save returns what every other adapter
returns.
290 291 292 293 294 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 290 def project(entry) return if entry.delete? Runtime::Instance.new(aggregate: @aggregate, id: entry.id, state: entry.state) end |
#query(declared, args = {}, context: {}) ⇒ Object
THE TWO-PHASE SHORTCUT (Track C, docs/implemented/postgres-era-adapter-
split-plan.md §3). SqlQueryBuilder#query (super, unmodified per
principle 2) always runs correctly here — it filters against
head_view, which is already the fully-reduced current state —
but for a domain that has minted a second era, that reduction
itself is the expensive part, and no index on the jsonb path
changes that (see field_cache.rb's own header for the SQL-
semantics reason why). When every where clause this query
declares either targets a cached field or is eligible to (an
ordinary comparator, not a null-vs-value special case — see
cache_eligible?), skip the reduction: look candidate ids up in
the cache table(s) first (cheap, indexed, no reduction involved),
then read ONLY those ids' current state from head_view — safe
THROUGH the reduction because id is its own partition key. Any
clause that ISN'T cache-eligible (an uncached field, or a null
comparison) is simply re-checked against head_view in the
second phase, exactly as super would have checked it anyway —
this can only ever NARROW what phase two has to look at, never
change what a clause means.
FALLS BACK TO super WHENEVER NO CLAUSE CAN BE ACCELERATED — a
query with no where at all (order_by-only — no cache table
exists for these, see field_cache.rb), a query whose only clauses
target fields with no cache table, or a domain that has never
minted a second era at all (@field_caches is never empty just
because era 1 has no reduction to skip — the cache tables still
exist and still accelerate era 1 the same way, but the fallback
path is already just as cheap there since head_view IS the
snapshot table verbatim for era 1; skipping straight to super
in that case would be a valid FUTURE optimization, not attempted
here to keep this one code path correct for every era uniformly).
225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 225 def query(declared, args = {}, context: {}) return super if @field_caches.empty? || declared.wheres.empty? evaluated = declared.wheres.map { |clause| [clause, query_value(clause.value, args)] } cached, uncached = evaluated.partition { |clause, value| cache_eligible?(clause, value) } return super if cached.empty? ids = cache_phase(cached) return [] if ids.empty? head_phase(declared, uncached, ids, args) end |
#record_event(event) ⇒ Object
329 330 331 332 333 334 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 329 def record_event(event) @db.exec_params( "INSERT INTO events (name, aggregate, aggregate_id, payload, occurred_at) VALUES ($1, $2, $3, $4, $5)", [event.name, event.aggregate, event.id.to_s, JSON.generate(event.payload), event.occurred_at] ) end |
#reset! ⇒ Object
312 313 314 315 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 312 def reset! @db.exec_params("DELETE FROM #{@lineage.quoted_journal} WHERE aggregate = $1", [table]) self end |
#save(instance) ⇒ Object
317 318 319 320 321 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 317 def save(instance) entry = Ports::Persistence::Entry.new(operation: "save", id: instance.id.to_s, state: instance.state.dup) append(entry) project(entry) end |
#save_saga(process_manager:, correlation:, state:, memory:) ⇒ Object
── the OPTIONAL saga-persistence capability (Ports::Persistence's
own three-method shape, §2) — one row per (domain, process_manager,
correlation), domain kept as an explicit column even under
schema isolation so two domains sharing one schema (neither
declares its own schema) still isolate correctly, matching
hecks_eras' own precedent (postgres/lineage/provisioning.rb).
No advisory lock of its own: every call here already runs inside
SagaInterpreter's own mutex (§7) serializing IN-PROCESS writers,
and gets the SAME cross-process safety an aggregate's own writes
get from this adapter — no better, no worse.
358 359 360 361 362 363 364 365 366 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 358 def save_saga(process_manager:, correlation:, state:, memory:) @db.exec_params( "INSERT INTO hecks_saga_instances (domain, process_manager, correlation, state, memory) " \ "VALUES ($1, $2, $3, $4, $5) " \ "ON CONFLICT (domain, process_manager, correlation) DO UPDATE " \ "SET state = EXCLUDED.state, memory = EXCLUDED.memory, updated_at = now()", [@domain, process_manager.to_s, correlation.to_s, state.to_s, JSON.generate(memory)] ) end |
#table ⇒ Object
164 |
# File 'lib/hecks/adapters/driven/postgres_era.rb', line 164 def table = @aggregate.storage_name |