smith-agents 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +8 -2
  4. data/docs/PERSISTENCE.md +180 -1
  5. data/docs/workflow_claim.md +4 -1
  6. data/lib/smith/agent/lifecycle.rb +30 -10
  7. data/lib/smith/doctor/checks/persistence_capabilities.rb +1 -0
  8. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  9. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  10. data/lib/smith/persistence_adapters/active_record_store.rb +91 -51
  11. data/lib/smith/persistence_adapters/cache_store.rb +3 -1
  12. data/lib/smith/persistence_adapters/memory.rb +29 -13
  13. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  14. data/lib/smith/persistence_adapters/redis_store.rb +11 -33
  15. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  16. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  17. data/lib/smith/persistence_adapters.rb +6 -1
  18. data/lib/smith/version.rb +1 -1
  19. data/lib/smith/workflow/durability.rb +1 -0
  20. data/lib/smith/workflow/persistence.rb +12 -2
  21. data/lib/smith/workflow/split_step_persistence/boundary.rb +115 -0
  22. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +121 -0
  23. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  24. data/lib/smith/workflow/split_step_persistence/execution.rb +126 -0
  25. data/lib/smith/workflow/split_step_persistence/inheritance.rb +20 -0
  26. data/lib/smith/workflow/split_step_persistence/payloads.rb +50 -0
  27. data/lib/smith/workflow/split_step_persistence/preparation.rb +97 -0
  28. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +91 -0
  29. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +46 -0
  30. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  31. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +52 -0
  32. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  33. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  34. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  35. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  36. data/lib/smith/workflow/split_step_persistence.rb +43 -0
  37. data/lib/smith/workflow.rb +13 -1
  38. data/lib/smith.rb +1 -0
  39. metadata +22 -1
@@ -17,7 +17,7 @@ module Smith
17
17
 
18
18
  def initialize(store:, namespace: "smith")
19
19
  @store_source = store
20
- @namespace = namespace
20
+ @namespace = namespace.nil? ? nil : namespace.to_s.dup.freeze
21
21
  end
22
22
 
23
23
  def store(key, payload, ttl: Smith.config.persistence_ttl)
@@ -42,6 +42,8 @@ module Smith
42
42
  end
43
43
  end
44
44
 
45
+ def transaction_open? = false
46
+
45
47
  def backend_name
46
48
  backend.class.name || backend.class.to_s
47
49
  end
@@ -23,21 +23,16 @@ module Smith
23
23
 
24
24
  def store(key, payload, ttl: Smith.config.persistence_ttl)
25
25
  @monitor.synchronize do
26
- @store[key] = { payload: payload, expires_at: ttl ? Time.now.utc + ttl : nil }
26
+ @store[key] = entry_for(payload, ttl)
27
27
  end
28
28
  end
29
29
 
30
30
  def fetch(key)
31
31
  @monitor.synchronize do
32
- entry = @store[key]
32
+ entry = live_entry(key)
33
33
  next nil if entry.nil?
34
34
 
35
- if entry[:expires_at] && entry[:expires_at] < Time.now.utc
36
- @store.delete(key)
37
- next nil
38
- end
39
-
40
- entry[:payload]
35
+ copy_payload(entry[:payload])
41
36
  end
42
37
  end
43
38
 
@@ -48,6 +43,8 @@ module Smith
48
43
  end
49
44
  end
50
45
 
46
+ def transaction_open? = false
47
+
51
48
  def record_heartbeat(key, ttl: Smith.config.persistence_ttl)
52
49
  @monitor.synchronize do
53
50
  @heartbeats[key] = { at: Time.now.utc, expires_at: ttl ? Time.now.utc + ttl : nil }
@@ -76,7 +73,7 @@ module Smith
76
73
  # across all versioned adapters).
77
74
  def store_versioned(key, payload, expected_version:, ttl: Smith.config.persistence_ttl)
78
75
  @monitor.synchronize do
79
- entry = @store[key]
76
+ entry = live_entry(key)
80
77
  if entry
81
78
  current_version = parse_version(entry[:payload])
82
79
  if current_version != expected_version
@@ -84,8 +81,10 @@ module Smith
84
81
  key: key, expected: expected_version, actual: current_version
85
82
  )
86
83
  end
84
+ else
85
+ VersionExpectation.validate_missing!(key, expected_version)
87
86
  end
88
- @store[key] = { payload: payload, expires_at: ttl ? Time.now.utc + ttl : nil }
87
+ @store[key] = entry_for(payload, ttl)
89
88
  end
90
89
  end
91
90
 
@@ -95,10 +94,27 @@ module Smith
95
94
 
96
95
  private
97
96
 
97
+ def live_entry(key)
98
+ entry = @store[key]
99
+ return unless entry
100
+ return entry unless entry[:expires_at] && entry[:expires_at] < Time.now.utc
101
+
102
+ @store.delete(key)
103
+ nil
104
+ end
105
+
106
+ def entry_for(payload, ttl)
107
+ { payload: copy_payload(payload), expires_at: ttl ? Time.now.utc + ttl : nil }
108
+ end
109
+
110
+ def copy_payload(payload)
111
+ payload.dup
112
+ rescue TypeError
113
+ payload
114
+ end
115
+
98
116
  def parse_version(payload)
99
- JSON.parse(payload).fetch("persistence_version", 0)
100
- rescue JSON::ParserError
101
- 0
117
+ PayloadVersion.call(payload)
102
118
  end
103
119
  end
104
120
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module PersistenceAdapters
5
+ module PayloadVersion
6
+ module_function
7
+
8
+ def call(payload)
9
+ document = payload.is_a?(String) ? JSON.parse(payload) : payload
10
+ raise TypeError, "persisted workflow payload must be a JSON object" unless document.is_a?(Hash)
11
+
12
+ version = document.fetch("persistence_version", 0)
13
+ unless version.is_a?(Integer) && version >= 0
14
+ raise TypeError, "persisted workflow persistence_version must be a non-negative integer"
15
+ end
16
+
17
+ version
18
+ rescue JSON::ParserError
19
+ 0
20
+ end
21
+ end
22
+ end
23
+ end
@@ -26,7 +26,7 @@ module Smith
26
26
 
27
27
  def initialize(redis:, namespace: "smith")
28
28
  @redis_source = redis
29
- @namespace = namespace
29
+ @namespace = namespace.nil? ? nil : namespace.to_s.dup.freeze
30
30
  end
31
31
 
32
32
  def store(key, payload, ttl: Smith.config.persistence_ttl)
@@ -51,6 +51,8 @@ module Smith
51
51
  end
52
52
  end
53
53
 
54
+ def transaction_open? = false
55
+
54
56
  def record_heartbeat(key, ttl: Smith.config.persistence_ttl)
55
57
  Retry.with_retries(operation: :record_heartbeat, transient: self.class.transient_errors) do
56
58
  iso = Time.now.utc.iso8601
@@ -78,32 +80,14 @@ module Smith
78
80
  # OR on EXEC failure (WATCH detected concurrent write).
79
81
  def store_versioned(key, payload, expected_version:, ttl: Smith.config.persistence_ttl)
80
82
  Retry.with_retries(operation: :store_versioned, transient: self.class.transient_errors) do
81
- namespaced_key = namespaced(key)
82
- result = client.watch(namespaced_key) do
83
- current = client.get(namespaced_key)
84
- if current && (current_version = parse_version(current)) != expected_version
85
- client.unwatch
86
- raise Smith::PersistenceVersionConflict.new(
87
- key: key, expected: expected_version, actual: current_version
88
- )
89
- end
90
-
91
- client.multi do |tx|
92
- if ttl
93
- tx.set(namespaced_key, payload, ex: ttl)
94
- else
95
- tx.set(namespaced_key, payload)
96
- end
97
- end
98
- end
99
-
100
- if result.nil?
101
- raise Smith::PersistenceVersionConflict.new(
102
- key: key, expected: expected_version, actual: :concurrent
103
- )
104
- end
105
-
106
- result
83
+ RedisVersionedWrite.new(
84
+ client: client,
85
+ key: key,
86
+ storage_key: namespaced(key),
87
+ payload: payload,
88
+ expected_version: expected_version,
89
+ ttl: ttl
90
+ ).call
107
91
  end
108
92
  end
109
93
 
@@ -125,12 +109,6 @@ module Smith
125
109
  def namespaced_heartbeat(key)
126
110
  [@namespace, "heartbeat", key].compact.join(":")
127
111
  end
128
-
129
- def parse_version(payload)
130
- JSON.parse(payload).fetch("persistence_version", 0)
131
- rescue JSON::ParserError
132
- 0
133
- end
134
112
  end
135
113
  end
136
114
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ module PersistenceAdapters
7
+ class RedisVersionedWrite
8
+ extend Dry::Initializer
9
+
10
+ option :client
11
+ option :key
12
+ option :storage_key
13
+ option :payload
14
+ option :expected_version
15
+ option :ttl
16
+
17
+ def call
18
+ result = client.watch(storage_key) do
19
+ current = client.get(storage_key)
20
+ validate_current!(current)
21
+ enqueue_write
22
+ end
23
+ return result unless result.nil?
24
+
25
+ raise PersistenceVersionConflict.new(
26
+ key: key,
27
+ expected: expected_version,
28
+ actual: :concurrent
29
+ )
30
+ end
31
+
32
+ private
33
+
34
+ def validate_current!(current)
35
+ return VersionExpectation.validate_missing!(key, expected_version) unless current
36
+
37
+ current_version = PayloadVersion.call(current)
38
+ return if current_version == expected_version
39
+
40
+ client.unwatch
41
+ raise PersistenceVersionConflict.new(
42
+ key: key,
43
+ expected: expected_version,
44
+ actual: current_version
45
+ )
46
+ end
47
+
48
+ def enqueue_write
49
+ client.multi do |transaction|
50
+ options = ttl ? { ex: ttl } : {}
51
+ transaction.set(storage_key, payload, **options)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module PersistenceAdapters
5
+ module VersionExpectation
6
+ module_function
7
+
8
+ def validate_missing!(key, expected_version)
9
+ return if expected_version.is_a?(Integer) && expected_version.zero?
10
+
11
+ raise Smith::PersistenceVersionConflict.new(
12
+ key: key,
13
+ expected: expected_version,
14
+ actual: :missing
15
+ )
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require_relative "persistence_adapters/active_record_connection_errors"
5
+ require_relative "persistence_adapters/active_record_initial_write"
6
+ require_relative "persistence_adapters/payload_version"
7
+ require_relative "persistence_adapters/version_expectation"
4
8
  require_relative "persistence_adapters/cache_store"
5
9
  require_relative "persistence_adapters/rails_cache"
10
+ require_relative "persistence_adapters/redis_versioned_write"
6
11
  require_relative "persistence_adapters/redis_store"
7
12
  require_relative "persistence_adapters/active_record_store"
8
13
  require_relative "persistence_adapters/memory"
@@ -23,7 +28,7 @@ module Smith
23
28
  # check support via `supports?(adapter, capability)` and fall back
24
29
  # gracefully (e.g., Workflow#persist! warns once and uses plain
25
30
  # `store` when `store_versioned` is missing).
26
- OPTIONAL_METHODS = %i[store_versioned record_heartbeat last_heartbeat].freeze
31
+ OPTIONAL_METHODS = %i[store_versioned record_heartbeat last_heartbeat transaction_open?].freeze
27
32
 
28
33
  def self.resolve(adapter, **options)
29
34
  return nil if adapter.nil?
data/lib/smith/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.4.3"
4
+ VERSION = "0.4.5"
5
5
  end
@@ -294,6 +294,7 @@ module Smith
294
294
  previous_version = @persistence_version || 0
295
295
  next_version = previous_version + 1
296
296
  payload = JSON.generate(to_state.merge(persistence_version: next_version))
297
+ payload = yield(payload, next_version) if block_given?
297
298
 
298
299
  dispatch_store!(store, resolved_key, payload, previous_version: previous_version)
299
300
 
@@ -48,7 +48,7 @@ module Smith
48
48
  # workflow class opts into idempotency_mode :strict. Restore
49
49
  # raises Smith::StepInProgressOnRestore if true under strict
50
50
  # mode. Lax mode leaves this false and never raises.
51
- step_in_progress: @step_in_progress || false,
51
+ step_in_progress: @step_in_progress || !@split_step_phase.nil?,
52
52
  # Keys recorded via DeterministicStep#write_context. Used by
53
53
  # persist :auto Context mode to scope the persisted context
54
54
  # slice. Always emitted (sorted for stable diffing) so
@@ -62,6 +62,7 @@ module Smith
62
62
  def restore_state(hash)
63
63
  migrated = migrate_if_needed(hash)
64
64
  normalized = normalize_persisted_state(migrated)
65
+ persistence_version = validated_persistence_version(normalized)
65
66
  restore_persisted_keys(normalized)
66
67
  restore_core_fields(normalized)
67
68
  @persistence_key = normalized[:persistence_key]
@@ -87,7 +88,7 @@ module Smith
87
88
  # Backward-compat: pre-versioning payloads have no key, restore to 0
88
89
  # so the first persist! after restore expects version 0 (matches
89
90
  # the original store from the legacy adapter contract).
90
- @persistence_version = normalized[:persistence_version] || 0
91
+ @persistence_version = persistence_version
91
92
  # Preserve the seed digest from the persisted payload so it
92
93
  # round-trips on subsequent persists. validate_seed_digest!
93
94
  # compares this against a fresh evaluation of the seed builder
@@ -98,9 +99,18 @@ module Smith
98
99
  # round-trips it. validate_step_in_progress! enforces strict
99
100
  # mode by raising if the marker is set on restore.
100
101
  @step_in_progress = normalized[:step_in_progress] || false
102
+ @split_step_mutex = Mutex.new
101
103
  validate_step_in_progress!(normalized) if self.class.idempotency_mode == :strict
102
104
  end
103
105
 
106
+ def validated_persistence_version(normalized)
107
+ version = normalized.fetch(:persistence_version, 0)
108
+ return version if version.is_a?(Integer) && version >= 0
109
+
110
+ raise Smith::SerializationError,
111
+ "persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
112
+ end
113
+
104
114
  def validate_step_in_progress!(normalized)
105
115
  return unless normalized[:step_in_progress] == true
106
116
 
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Boundary
7
+ def run_persisted!(...)
8
+ ensure_no_split_step_boundary!
9
+ super
10
+ end
11
+
12
+ def advance_persisted!(...)
13
+ ensure_no_split_step_boundary!
14
+ super
15
+ end
16
+
17
+ def clear_persisted!(...)
18
+ ensure_no_split_step_boundary!
19
+ super
20
+ end
21
+
22
+ def initialize_copy(source)
23
+ super
24
+ @split_step_mutex = Mutex.new
25
+ return unless source.instance_variable_get(:@split_step_phase)
26
+
27
+ @split_step_phase = :copied_boundary
28
+ @split_step_token = nil
29
+ @split_step_execution_thread = nil
30
+ @split_step_advance_permit = false
31
+ @split_step_preparation_thread = nil
32
+ @split_step_persist_permit = false
33
+ end
34
+ private :initialize_copy
35
+
36
+ private
37
+
38
+ def ensure_split_step_execution_allowed!
39
+ @split_step_mutex.synchronize do
40
+ return unless @split_step_phase
41
+
42
+ if split_step_advance_permitted?
43
+ @split_step_advance_permit = false
44
+ return
45
+ end
46
+ end
47
+
48
+ raise WorkflowError, "use execute_prepared_step! for the active split-step boundary"
49
+ end
50
+
51
+ def claim_split_step_advance!
52
+ @split_step_mutex.synchronize do
53
+ unless @split_step_phase
54
+ @split_step_phase = :ordinary_execution
55
+ return :ordinary
56
+ end
57
+ if split_step_advance_permitted?
58
+ @split_step_advance_permit = false
59
+ return :split_step
60
+ end
61
+
62
+ raise WorkflowError, "use execute_prepared_step! for the active split-step boundary"
63
+ end
64
+ end
65
+
66
+ def release_split_step_advance!(claim)
67
+ return unless claim == :ordinary
68
+
69
+ @split_step_mutex.synchronize do
70
+ @split_step_phase = nil if @split_step_phase == :ordinary_execution
71
+ end
72
+ end
73
+
74
+ def ensure_no_split_step_boundary!
75
+ return unless @split_step_phase
76
+
77
+ raise WorkflowError, "a split-step persistence boundary is already active"
78
+ end
79
+
80
+ def guard_split_step_subclass_execution!
81
+ @split_step_mutex.synchronize do
82
+ return unless @split_step_phase
83
+ return if split_step_advance_permitted?
84
+ end
85
+
86
+ raise WorkflowError, "use execute_prepared_step! for the active split-step boundary"
87
+ end
88
+
89
+ def split_step_advance_permitted?
90
+ @split_step_phase == :executing &&
91
+ @split_step_execution_thread.equal?(Thread.current) &&
92
+ @split_step_advance_permit
93
+ end
94
+
95
+ def clear_split_step_boundary!
96
+ @split_step_phase = nil
97
+ @split_step_transition_name = nil
98
+ @split_step_transition = nil
99
+ @split_step_transition_signature = nil
100
+ @split_step_origin_state = nil
101
+ @split_step_token = nil
102
+ @split_step_persistence_key = nil
103
+ @split_step_adapter = nil
104
+ remove_instance_variable(:@split_step_persistence_ttl) if
105
+ instance_variable_defined?(:@split_step_persistence_ttl)
106
+ @split_step_preparation_payload = nil
107
+ @split_step_checkpoint_digest = nil
108
+ @split_step_checkpoint_version = nil
109
+ @split_step_preparation_thread = nil
110
+ @split_step_persist_permit = false
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Checkpoint
7
+ def complete_persisted_step!
8
+ completion_claimed = false
9
+ previous_phase = claim_split_step_completion!
10
+ completion_claimed = true
11
+ completed = false
12
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
13
+ unless persisted_split_step_checkpoint?(payload)
14
+ recover_rolled_back_split_step_checkpoint!(payload)
15
+ raise WorkflowError, "the persisted split-step checkpoint is not committed"
16
+ end
17
+
18
+ @split_step_mutex.synchronize do
19
+ @persistence_version = @split_step_checkpoint_version
20
+ clear_step_in_progress!
21
+ clear_split_step_boundary!
22
+ end
23
+ completed = true
24
+ self
25
+ ensure
26
+ restore_incomplete_checkpoint!(previous_phase) if completion_claimed && !completed
27
+ end
28
+
29
+ def persist!(key = nil, adapter: Smith.persistence_adapter)
30
+ checkpoint_claimed = false
31
+ resolved_key, store = resolve_split_step_target(key, adapter)
32
+ checkpoint_claimed = claim_split_step_checkpoint!
33
+ result = super(resolved_key, adapter: store) do |payload, next_version|
34
+ capture_split_step_payload!(payload, next_version, checkpoint_claimed:)
35
+ end
36
+ @split_step_mutex.synchronize { @split_step_phase = :checkpointed } if checkpoint_claimed
37
+ result
38
+ rescue StandardError
39
+ mark_split_step_checkpoint_unknown! if checkpoint_claimed
40
+ raise
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_split_step_target(key, adapter)
46
+ resolved_key = if @split_step_persistence_key
47
+ candidate = candidate_split_step_persistence_key(key)
48
+ if candidate.equal?(@split_step_persistence_key)
49
+ candidate
50
+ else
51
+ normalize_split_step_persistence_key(candidate)
52
+ end
53
+ else
54
+ resolve_persistence_key!(key)
55
+ end
56
+ store = persistence_adapter!(adapter)
57
+ validate_split_step_target!(resolved_key, store)
58
+ resolved_key = @split_step_persistence_key if @split_step_persistence_key
59
+ @persistence_key = resolved_key
60
+ [resolved_key, store]
61
+ end
62
+
63
+ def claim_split_step_checkpoint!
64
+ @split_step_mutex.synchronize do
65
+ if split_step_preparation_persist_permitted?
66
+ @split_step_persist_permit = false
67
+ return false
68
+ end
69
+ return false unless @split_step_phase
70
+
71
+ if %i[executed checkpoint_retryable].include?(@split_step_phase)
72
+ @split_step_phase = :checkpointing
73
+ return true
74
+ end
75
+
76
+ raise WorkflowError, "the active split-step boundary cannot be checkpointed"
77
+ end
78
+ end
79
+
80
+ def split_step_preparation_persist_permitted?
81
+ @split_step_phase == :preparing &&
82
+ @split_step_preparation_thread.equal?(Thread.current) &&
83
+ @split_step_persist_permit
84
+ end
85
+
86
+ def validate_split_step_target!(key, adapter)
87
+ return unless @split_step_persistence_key
88
+ return if key == @split_step_persistence_key && adapter.equal?(@split_step_adapter)
89
+
90
+ raise WorkflowError, "the split-step persistence target cannot change"
91
+ end
92
+
93
+ def capture_split_step_payload!(payload, next_version, checkpoint_claimed:)
94
+ validate_split_step_marker!(payload, expected: true) if @split_step_phase == :preparing || checkpoint_claimed
95
+ if @split_step_phase == :preparing
96
+ payload = split_step_preparation_payload(payload)
97
+ @split_step_preparation_payload = payload
98
+ end
99
+ return payload unless checkpoint_claimed
100
+
101
+ checkpoint_payload = split_step_checkpoint_payload(payload)
102
+ checkpoint_digest = Digest::SHA256.hexdigest(checkpoint_payload)
103
+ validate_split_step_retry_payload!(checkpoint_digest)
104
+ @split_step_checkpoint_digest = checkpoint_digest
105
+ @split_step_checkpoint_version = next_version
106
+ checkpoint_payload
107
+ end
108
+
109
+ def validate_split_step_retry_payload!(checkpoint_digest)
110
+ return unless @split_step_checkpoint_digest
111
+ return if @split_step_checkpoint_digest == checkpoint_digest
112
+
113
+ @split_step_mutex.synchronize do
114
+ @split_step_phase = :checkpoint_retryable if @split_step_phase == :checkpointing
115
+ end
116
+ raise WorkflowError, "the reconciled split-step checkpoint payload changed"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module CheckpointState
7
+ private
8
+
9
+ def claim_split_step_completion!
10
+ @split_step_mutex.synchronize do
11
+ unless %i[checkpoint_unknown checkpointed].include?(@split_step_phase)
12
+ raise WorkflowError, "no persisted split-step checkpoint is awaiting completion"
13
+ end
14
+ if Smith::PersistenceAdapters.supports?(@split_step_adapter, :transaction_open?) &&
15
+ @split_step_adapter.transaction_open?
16
+ raise WorkflowError, "the split-step checkpoint transaction is still open"
17
+ end
18
+
19
+ previous_phase = @split_step_phase
20
+ @split_step_phase = :confirming_checkpoint
21
+ previous_phase
22
+ end
23
+ end
24
+
25
+ def restore_incomplete_checkpoint!(previous_phase)
26
+ @split_step_mutex.synchronize do
27
+ @split_step_phase = previous_phase if @split_step_phase == :confirming_checkpoint
28
+ end
29
+ end
30
+
31
+ def recover_rolled_back_split_step_checkpoint!(payload)
32
+ return unless persisted_split_step_payload?(payload, @split_step_preparation_payload)
33
+
34
+ preparation_version = JSON.parse(payload).fetch("persistence_version")
35
+ @split_step_mutex.synchronize do
36
+ return unless @split_step_phase == :confirming_checkpoint
37
+
38
+ @persistence_version = preparation_version
39
+ @split_step_phase = :checkpoint_retryable
40
+ end
41
+ end
42
+
43
+ def mark_split_step_checkpoint_unknown!
44
+ @split_step_mutex.synchronize do
45
+ @split_step_phase = :checkpoint_unknown if @split_step_phase == :checkpointing
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end