Class: Clickwrap::ChainHead

Inherits:
ApplicationRecord show all
Defined in:
lib/clickwrap/models/chain_head.rb

Overview

The head of one tamper-evident event chain.

Chaining is off unless configured. The scope is tenant-and-policy: a tenanted installation gets one chain per (tenant, policy). An UNTENANTED installation's scope is the literal "global/<policy_key>" — one chain per policy across all actors, which means every capture of that policy serializes behind every other. Enable chaining on hot policies knowing that queue exists; per-actor scoping is future work, not current behavior.

What a chain detects: an event rewritten or removed after the fact, as long as the head remains trustworthy. What it does not do: stop a party with full control of the application and database from rewriting both the events and the head. That is what the optional independent anchor adapter is for, and even then the claim is only as strong as the anchor.

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.record!(chain_scope:, event_id:, event_digest:) ⇒ Object

Records the digest the event was actually written with, so the next event in this scope links to something real.



69
70
71
72
73
74
75
# File 'lib/clickwrap/models/chain_head.rb', line 69

def self.record!(chain_scope:, event_id:, event_digest:)
  head = lock.find_by(chain_scope: chain_scope)
  return nil unless head

  head.update!(last_event_id: event_id, last_event_digest: event_digest)
  head
end

.reserve!(chain_scope:) ⇒ Object

Takes the next position in the chain. The row lock is what stops two concurrent captures in the same scope from reading the same predecessor and forking the chain.



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
# File 'lib/clickwrap/models/chain_head.rb', line 37

def self.reserve!(chain_scope:)
  # The first probe must be an ordinary read. On MySQL/InnoDB, two
  # `SELECT ... FOR UPDATE` calls for the same absent unique key both take
  # gap locks; when both then INSERT, InnoDB has to deadlock one of them.
  # Let the unique INSERT choose the first writer, absorb the loser's
  # duplicate in a savepoint, and only then take the row lock.
  head = find_by(chain_scope: chain_scope)

  unless head
    begin
      transaction(requires_new: true) { create!(chain_scope: chain_scope, chain_sequence: 0) }
    rescue ActiveRecord::RecordNotUnique
      # The savepoint absorbs PostgreSQL's aborted-statement state before
      # the winning row is read and locked in the caller's transaction.
    end
  end

  # Always re-read under the lock. A plain read above is only an existence
  # probe and may carry a stale sequence; this is the value from which the
  # next link is actually reserved.
  head = lock.find_by!(chain_scope: chain_scope)

  next_sequence = head.chain_sequence + 1
  previous_digest = head.last_event_digest

  head.update!(chain_sequence: next_sequence)

  [previous_digest, next_sequence]
end

Instance Method Details

#to_sObject



77
# File 'lib/clickwrap/models/chain_head.rb', line 77

def to_s = "chain #{chain_scope} at #{chain_sequence}"