Class: InstantRecord::MutationApplier

Inherits:
Object
  • Object
show all
Defined in:
app/models/instant_record/mutation_applier.rb

Overview

Applies one client mutation authoritatively: record write, version bump, change-log row, and idempotency-ledger row, in one transaction. This is the server half of the sync protocol; the HTTP layer above it is thin.

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(mutation) ⇒ MutationApplier

Returns a new instance of MutationApplier.



10
11
12
13
# File 'app/models/instant_record/mutation_applier.rb', line 10

def initialize(mutation)
  @mutation = mutation
  @changes = (mutation[:changes] || {}).to_h
end

Class Method Details

.apply(mutation) ⇒ Object



6
7
8
# File 'app/models/instant_record/mutation_applier.rb', line 6

def self.apply(mutation)
  new(mutation).apply
end

Instance Method Details

#applyObject

Replayed mutation ids return their original result without re-applying.



16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'app/models/instant_record/mutation_applier.rb', line 16

def apply
  if (existing = AppliedMutation.find_by(mutation_id: @mutation[:id]))
    return existing.result_payload.symbolize_keys
  end

  model = InstantRecord.synced_model(@mutation[:record_type])
  return record_result(status: "rejected", reason: "unknown record type") unless model

  result = ActiveRecord::Base.transaction do
    InstantRecord.applying_client_mutation { perform(model) }
  end
  record_result(**result)
rescue ActiveRecord::RecordInvalid => e
  record_result(status: "rejected", reason: e.record.errors.full_messages.to_sentence,
    server_attributes: server_attributes_for(model))
rescue ActiveRecord::RecordNotUnique
  # A create whose id already exists (e.g. a client replaying rows the
  # server already has). Rejecting lets the client roll back its local
  # copy instead of retrying the poisoned mutation forever; the server's
  # row comes back down through the change stream.
  record_result(status: "rejected", reason: "id already exists",
    server_attributes: server_attributes_for(model))
rescue ActiveModel::UnknownAttributeError => e
  # Schema skew: this client is a migration ahead of this server and sent a
  # column that does not exist here. Rejecting is the only outcome that
  # cannot jam the queue. Raising 500s the entire batch, and because the
  # client keeps the row on a transport error it would retry the same
  # poisoned mutation forever, blocking every write queued behind it.
  # Slicing the unknown column off instead would answer "applied" for a
  # write the server only partly stored — silent data loss the client has no
  # way to notice. A surfaced rejection loses the same write loudly, rolls
  # the local record back to what the server actually holds, and leaves the
  # queue draining. Deploy order (server first) remains the real defence.
  record_result(status: "rejected", reason: e.message,
    server_attributes: server_attributes_for(model))
end