Module: Hecks::Adapters::PostgresEra::Lineage::TailMerge
- Included in:
- Hecks::Adapters::PostgresEra::Lineage
- Defined in:
- lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb
Instance Method Summary collapse
-
#conflict_ids(aggregate, edges, era, cut) ⇒ Object
Ids touched by BOTH worlds since the cut — the old world's post-cut tail INTERSECTed with the new world's own writes.
-
#diverged_count(old_era) ⇒ Object
Post-cut writes an old era made after the newer era was minted — the divergence between the worlds, observable at any time.
-
#merge_tail!(aggregates:, edges:, winners: {}, audit: nil) ⇒ Object
── tail-merge ─────────────────────────────────────────────────.
Instance Method Details
#conflict_ids(aggregate, edges, era, cut) ⇒ Object
Ids touched by BOTH worlds since the cut — the old world's post-cut tail INTERSECTed with the new world's own writes.
KNOWN GAP, not silently risked: this compares raw
aggregate_id values, with no notion of "these two different
ids are the same entity, rekeyed." If a domain's history
includes a rekey (see TranslationRekey) and is LATER
merged here, a record's pre-rekey and post-rekey rows will
never intersect — they just silently survive as two separate,
unrelated-looking heads (a duplicate, not corruption: nothing
here deletes or clobbers either side). Resolve any such
duplicate manually after a merge; teaching this INTERSECT
about a rekey mapping is real, separate work, deliberately
out of scope for rekey's first pass.
148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb', line 148 def conflict_ids(aggregate, edges, era, cut) names = names_by_era(aggregate, edges) olds = (1...era).map { |ancestor| text_literal(names[:storage][ancestor - 1]) }.join(", ") @db.exec(<<~SQL).map { |row| [aggregate.storage_name, row["aggregate_id"]] } SELECT aggregate_id FROM #{quoted_journal} WHERE era < #{era.to_i} AND aggregate IN (#{olds}) AND ordinal > #{cut.to_i} INTERSECT SELECT aggregate_id FROM #{quoted_journal} WHERE era = #{era.to_i} AND aggregate = #{text_literal(names[:storage][era - 1])} SQL end |
#diverged_count(old_era) ⇒ Object
Post-cut writes an old era made after the newer era was minted — the divergence between the worlds, observable at any time.
12 13 14 15 16 17 18 19 |
# File 'lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb', line 12 def diverged_count(old_era) cut = eras.find { |era| era[:ordinal] == old_era + 1 }&.dig(:watermark) return 0 unless cut @db.exec( "SELECT count(*) FROM #{quoted_journal} WHERE era = #{old_era.to_i} AND ordinal > #{cut.to_i}" )[0]["count"].to_i end |
#merge_tail!(aggregates:, edges:, winners: {}, audit: nil) ⇒ Object
── tail-merge ─────────────────────────────────────────────────
The one deliberate command — it marks a business event (an app retiring), never a shape change. One transaction: advance the watermarks, rebuild the head so the tail interleaves by its recorded global ordinal, append the declared winners, audit — and roll the whole thing back on any refusal. Records touched by both worlds since the cut refuse until each has an explicit winner; resolution itself is append-only (the winner's state re-enters as the newest row and wins structurally — originals stay immutable).
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 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 128 129 130 131 132 |
# File 'lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb', line 32 def merge_tail!(aggregates:, edges:, winners: {}, audit: nil) @db.exec("BEGIN") @db.exec("SET LOCAL lock_timeout = '10s'") @db.exec("SELECT pg_advisory_xact_lock(hashtext('hecks_eras:' || #{text_literal(@domain)}))") held = eras era = held.last[:ordinal] label = held.last[:label] if era == 1 @db.exec("ROLLBACK") raise Runtime::WiringError, "nothing to merge — #{@domain} stands at era 1" end cut = held.last[:watermark].to_i tip = last_ordinal conflicts = aggregates.flat_map { |aggregate| conflict_ids(aggregate, edges, era, cut) } unresolved = conflicts.reject { |_, id| winners.key?(id) } unless unresolved.empty? @db.exec("ROLLBACK") raise Runtime::WiringError, "cannot merge the tail of #{@domain}: touched by both worlds since the cut — " \ "#{unresolved.map { |storage, id| "#{storage}##{id}" }.sort.join(', ')}. " \ "Name each winner (--winner <id>=old or --winner <id>=new), then run bin/merge_tail again. " \ "A winner takes the WHOLE record — the aggregate is the consistency boundary, so the " \ "loser's edits are discarded even where they touched different attributes" end # the new world's pre-merge head states, captured before the # rebuild lets the tail interleave new_states = {} aggregates.each do |aggregate| winners.select { |_, side| side == "new" }.each_key do |id| row = @db.exec_params("SELECT state FROM #{quote(head_view(aggregate.storage_name))} WHERE id = $1", [id]) new_states[id] = [aggregate.storage_name, row[0]["state"]] if row.ntuples.positive? end end @db.exec_params("UPDATE hecks_eras SET watermark = $2 WHERE domain = $1 AND ordinal > 1", [@domain, tip]) aggregates.each do |aggregate| @db.exec("DROP VIEW IF EXISTS #{quote(head_view(aggregate.storage_name))}") @db.exec("DROP MATERIALIZED VIEW IF EXISTS #{quote(matview(aggregate.storage_name, era, label))}") # full: the watermarks just moved — every ancestor matview's # cut is stale, so there is nothing safe to layer on. compile_head!(aggregate, era, label, edges, full: true) end winners.each do |id, side| aggregates.each do |aggregate| state = if side == "old" row = @db.exec_params( "SELECT state FROM #{quote(matview(aggregate.storage_name, era, label))} " \ "WHERE aggregate_id = $1 AND operation = 'save' ORDER BY ordinal DESC LIMIT 1", [id] ) row.ntuples.positive? ? row[0]["state"] : nil else new_states[id]&.first == aggregate.storage_name ? new_states[id][1] : nil end next unless state # Same two-step append does for a live write — journal # first, snapshot second — because this INSERT bypasses # PostgresEra#append entirely (it writes through Lineage # directly). Skipping the snapshot half here would mean a # merge winner lands in the journal but head_view — which # reads this era's live rows from the snapshot table, not # by re-scanning the journal — never shows it. ordinal = @db.exec_params( "INSERT INTO #{quoted_journal} (era, aggregate, aggregate_id, operation, state) " \ "VALUES ($1, $2, $3, 'save', $4) RETURNING ordinal", [era, aggregate.storage_name, id, state] )[0]["ordinal"] @db.exec_params( "INSERT INTO #{quote(head_snapshot(aggregate.storage_name, era))} (id, ordinal, state) VALUES ($1, $2, $3) " \ "ON CONFLICT (id) DO UPDATE SET ordinal = EXCLUDED.ordinal, state = EXCLUDED.state " \ "WHERE #{quote(head_snapshot(aggregate.storage_name, era))}.ordinal < EXCLUDED.ordinal", [id, ordinal, state] ) end end if audit violations = audit.call unless violations.empty? @db.exec("ROLLBACK") raise Runtime::WiringError, "cannot merge the tail of #{@domain}: the audit refused —\n - #{violations.join("\n - ")}" end end @db.exec("COMMIT") true rescue PG::LockNotAvailable @db.exec("ROLLBACK") rescue nil raise Runtime::WiringError, "cannot merge the tail of #{@domain}: another mint or merge holds the domain lock — " \ "waited 10s; try again shortly" rescue PG::Error => error @db.exec("ROLLBACK") rescue nil raise Runtime::WiringError, "cannot merge the tail of #{@domain}: #{error..strip}" end |