smith-agents 0.4.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +8 -2
- data/docs/PERSISTENCE.md +180 -1
- data/docs/workflow_claim.md +4 -1
- data/lib/smith/doctor/checks/persistence_capabilities.rb +1 -0
- data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
- data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +91 -51
- data/lib/smith/persistence_adapters/cache_store.rb +3 -1
- data/lib/smith/persistence_adapters/memory.rb +29 -13
- data/lib/smith/persistence_adapters/payload_version.rb +23 -0
- data/lib/smith/persistence_adapters/redis_store.rb +11 -33
- data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
- data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
- data/lib/smith/persistence_adapters.rb +6 -1
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/durability.rb +1 -0
- data/lib/smith/workflow/persistence.rb +12 -2
- data/lib/smith/workflow/split_step_persistence/boundary.rb +115 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +121 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +126 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +20 -0
- data/lib/smith/workflow/split_step_persistence/payloads.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +97 -0
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +91 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +46 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +52 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
- data/lib/smith/workflow/split_step_persistence.rb +43 -0
- data/lib/smith/workflow.rb +13 -1
- data/lib/smith.rb +1 -0
- metadata +22 -1
|
@@ -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] =
|
|
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 =
|
|
32
|
+
entry = live_entry(key)
|
|
33
33
|
next nil if entry.nil?
|
|
34
34
|
|
|
35
|
-
|
|
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 =
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
@@ -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 ||
|
|
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 =
|
|
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
|