smith-agents 0.4.4 → 0.5.0
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 +262 -0
- data/README.md +116 -6
- data/docs/CONFIGURATION.md +4 -1
- data/docs/PATTERNS.md +7 -0
- data/docs/PERSISTENCE.md +502 -1
- data/docs/workflow_claim.md +4 -1
- data/lib/smith/agent/lifecycle.rb +19 -10
- data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
- data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
- data/lib/smith/agent/registry.rb +8 -0
- data/lib/smith/agent.rb +24 -2
- data/lib/smith/budget/amount_contract.rb +67 -0
- data/lib/smith/budget/amount_representation.rb +43 -0
- data/lib/smith/budget/decimal_context.rb +18 -0
- data/lib/smith/budget/ledger.rb +110 -18
- data/lib/smith/budget/ledger_publication.rb +60 -0
- data/lib/smith/budget/ledger_state.rb +21 -0
- data/lib/smith/budget/ledger_state_transition.rb +72 -0
- data/lib/smith/budget/public_state_validator.rb +25 -0
- data/lib/smith/budget/reservation.rb +20 -0
- data/lib/smith/budget/reservation_contract.rb +34 -0
- data/lib/smith/context/observation_masking.rb +3 -1
- data/lib/smith/context/state_injection.rb +1 -1
- data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
- data/lib/smith/error.rb +5 -0
- data/lib/smith/errors.rb +6 -0
- data/lib/smith/exponential_backoff.rb +128 -0
- data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
- data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
- data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
- data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
- data/lib/smith/persistence_adapters/cache_store.rb +4 -1
- data/lib/smith/persistence_adapters/memory.rb +45 -14
- data/lib/smith/persistence_adapters/payload_version.rb +23 -0
- data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
- data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
- data/lib/smith/persistence_adapters/redis_store.rb +38 -43
- data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
- data/lib/smith/persistence_adapters/retry.rb +22 -10
- data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
- data/lib/smith/persistence_adapters.rb +26 -18
- data/lib/smith/persistence_payload_conflict.rb +12 -0
- data/lib/smith/tool/budget_enforcement.rb +2 -2
- data/lib/smith/types.rb +1 -0
- data/lib/smith/version.rb +2 -1
- data/lib/smith/workflow/branch_env.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +21 -11
- data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
- data/lib/smith/workflow/definition_identity.rb +25 -0
- data/lib/smith/workflow/deterministic_step.rb +2 -2
- data/lib/smith/workflow/dsl.rb +73 -5
- data/lib/smith/workflow/durability.rb +2 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
- data/lib/smith/workflow/execution.rb +16 -18
- data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
- data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/graph/diagnostic.rb +27 -1
- data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
- data/lib/smith/workflow/graph/execution_successors.rb +65 -0
- data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
- data/lib/smith/workflow/graph/metrics.rb +1 -3
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
- data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
- data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
- data/lib/smith/workflow/graph/reachability.rb +19 -31
- data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
- data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/transition_contract.rb +47 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
- data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
- data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
- data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
- data/lib/smith/workflow/graph/validator.rb +1 -1
- data/lib/smith/workflow/graph.rb +62 -4
- data/lib/smith/workflow/guardrail_integration.rb +23 -9
- data/lib/smith/workflow/identifier.rb +24 -0
- data/lib/smith/workflow/message_admission.rb +40 -0
- data/lib/smith/workflow/message_admission_boundary.rb +22 -0
- data/lib/smith/workflow/message_batch.rb +60 -0
- data/lib/smith/workflow/message_value_normalizer.rb +133 -0
- data/lib/smith/workflow/nested_execution.rb +10 -0
- data/lib/smith/workflow/orchestrator_worker.rb +3 -7
- data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
- data/lib/smith/workflow/parallel/execution_context.rb +113 -0
- data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
- data/lib/smith/workflow/parallel/root_execution.rb +91 -0
- data/lib/smith/workflow/parallel.rb +19 -22
- data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
- data/lib/smith/workflow/parallel_execution.rb +7 -3
- data/lib/smith/workflow/persistence.rb +49 -10
- data/lib/smith/workflow/prepared_step.rb +122 -0
- data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
- data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
- data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
- data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
- data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
- data/lib/smith/workflow/retry_execution.rb +20 -11
- data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
- data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
- data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
- data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
- data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
- data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
- data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
- data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
- data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
- data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -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 +79 -0
- data/lib/smith/workflow/step_completion.rb +31 -0
- data/lib/smith/workflow/string_snapshot.rb +25 -0
- data/lib/smith/workflow/transition.rb +122 -133
- data/lib/smith/workflow/transition_actionability.rb +22 -0
- data/lib/smith/workflow.rb +57 -19
- data/lib/smith.rb +42 -2
- data/sig/smith.rbs +1 -0
- metadata +131 -19
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module SplitStepPersistence
|
|
6
|
+
module Preparation
|
|
7
|
+
DESCRIPTOR_PHASES = %i[
|
|
8
|
+
prepared_uncommitted prepared verifying_execution execution_authorized executing executed attempted
|
|
9
|
+
dispatch_claimed_uncommitted dispatch_claimed dispatch_rejected dispatch_unknown
|
|
10
|
+
confirming_dispatch checkpointing checkpoint_retryable checkpoint_unknown
|
|
11
|
+
checkpointed confirming_checkpoint
|
|
12
|
+
].freeze
|
|
13
|
+
private_constant :DESCRIPTOR_PHASES
|
|
14
|
+
|
|
15
|
+
def prepare_persisted_step!(key = nil, adapter: Smith.persistence_adapter)
|
|
16
|
+
ensure_strict_split_step_persistence!
|
|
17
|
+
intent_claimed = claim_split_step_preparation_intent!
|
|
18
|
+
return unless intent_claimed
|
|
19
|
+
|
|
20
|
+
preparation_finalized = false
|
|
21
|
+
prepare_claimed_persisted_step!(key, adapter)
|
|
22
|
+
preparation_finalized = true
|
|
23
|
+
persist_claimed_split_step_preparation!(@split_step_persistence_key, @split_step_adapter)
|
|
24
|
+
@split_step_transition_name
|
|
25
|
+
ensure
|
|
26
|
+
release_split_step_preparation_intent! if intent_claimed && !preparation_finalized
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def confirm_prepared_step!
|
|
30
|
+
confirmation_claimed = false
|
|
31
|
+
claim_split_step_confirmation!
|
|
32
|
+
confirmation_claimed = true
|
|
33
|
+
confirmed = false
|
|
34
|
+
payload = @split_step_adapter.fetch(@split_step_persistence_key)
|
|
35
|
+
raise WorkflowError, "the persisted split-step preparation is not committed" unless
|
|
36
|
+
persisted_split_step_payload?(payload, @split_step_preparation_payload)
|
|
37
|
+
|
|
38
|
+
@split_step_mutex.synchronize do
|
|
39
|
+
@split_step_phase = :prepared
|
|
40
|
+
@split_step_transaction_thread = nil
|
|
41
|
+
@split_step_transaction_identity = nil
|
|
42
|
+
end
|
|
43
|
+
confirmed = true
|
|
44
|
+
self
|
|
45
|
+
ensure
|
|
46
|
+
restore_unconfirmed_preparation! if confirmation_claimed && !confirmed
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def prepared_persisted_step
|
|
50
|
+
phase, descriptor, adapter, transaction_identity = @split_step_mutex.synchronize do
|
|
51
|
+
[
|
|
52
|
+
@split_step_phase,
|
|
53
|
+
@split_step_prepared_descriptor,
|
|
54
|
+
@split_step_adapter,
|
|
55
|
+
@split_step_transaction_identity
|
|
56
|
+
]
|
|
57
|
+
end
|
|
58
|
+
return unless DESCRIPTOR_PHASES.include?(phase) && descriptor
|
|
59
|
+
return descriptor unless phase == :prepared_uncommitted
|
|
60
|
+
|
|
61
|
+
descriptor if TransactionIdentity.matches?(adapter, transaction_identity)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def ensure_strict_split_step_persistence!
|
|
67
|
+
return if strict_idempotency?
|
|
68
|
+
|
|
69
|
+
raise WorkflowError, "split-step persistence requires idempotency_mode :strict"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prepare_claimed_persisted_step!(key, adapter)
|
|
73
|
+
transition = pending_split_step_transition
|
|
74
|
+
transition_name = @next_transition_name || transition&.name
|
|
75
|
+
raise WorkflowError, "workflow has no pending transition" unless transition_name
|
|
76
|
+
|
|
77
|
+
ensure_transition_budget!
|
|
78
|
+
resolved_key = normalize_split_step_persistence_key(candidate_split_step_persistence_key(key))
|
|
79
|
+
store = persistence_adapter!(adapter)
|
|
80
|
+
persistence_ttl = validate_split_step_adapter!(store)
|
|
81
|
+
finalize_split_step_preparation!(transition, transition_name, resolved_key, store, persistence_ttl)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pending_split_step_transition
|
|
85
|
+
transition = if @next_transition_name
|
|
86
|
+
self.class.find_transition(@next_transition_name)
|
|
87
|
+
else
|
|
88
|
+
self.class.first_transition_from(@state)
|
|
89
|
+
end
|
|
90
|
+
validate_transition_origin!(transition) if transition
|
|
91
|
+
transition
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def candidate_split_step_persistence_key(key)
|
|
95
|
+
return key unless key.nil? || blank_key?(key)
|
|
96
|
+
return @persistence_key unless blank_key?(@persistence_key)
|
|
97
|
+
|
|
98
|
+
self.class.send(:resolve_persistence_key!, key:, context: @context)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalize_split_step_persistence_key(key) = key.to_s.dup.freeze
|
|
102
|
+
|
|
103
|
+
def validate_split_step_adapter!(adapter)
|
|
104
|
+
unless Smith::PersistenceAdapters.supports?(adapter, :store_versioned)
|
|
105
|
+
raise WorkflowError, "split-step persistence requires an adapter with store_versioned"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
validate_restart_safe_adapter!(adapter)
|
|
109
|
+
unless split_step_adapter_accepts_explicit_ttl?(adapter)
|
|
110
|
+
raise WorkflowError, "split-step persistence requires store_versioned to accept ttl:"
|
|
111
|
+
end
|
|
112
|
+
return unless effective_persistence_ttl
|
|
113
|
+
|
|
114
|
+
raise WorkflowError, "split-step persistence requires non-expiring workflow persistence"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def split_step_adapter_accepts_explicit_ttl?(adapter)
|
|
118
|
+
adapter.method(:store_versioned).parameters.any? do |kind, name|
|
|
119
|
+
kind == :keyrest || (%i[key keyreq].include?(kind) && name == :ttl)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module SplitStepPersistence
|
|
6
|
+
module PreparationClaim
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def claim_split_step_preparation_intent!
|
|
10
|
+
@split_step_mutex.synchronize do
|
|
11
|
+
ensure_no_split_step_boundary!
|
|
12
|
+
return false if terminal?
|
|
13
|
+
|
|
14
|
+
@split_step_phase = :claiming_preparation
|
|
15
|
+
@split_step_preparation_thread = Thread.current
|
|
16
|
+
@split_step_definition_digest = self.class.send(:seal_definition_identity!)
|
|
17
|
+
true
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def release_split_step_preparation_intent!
|
|
22
|
+
@split_step_mutex.synchronize do
|
|
23
|
+
next unless @split_step_phase == :claiming_preparation
|
|
24
|
+
next unless @split_step_preparation_thread.equal?(Thread.current)
|
|
25
|
+
|
|
26
|
+
@split_step_phase = nil
|
|
27
|
+
@split_step_preparation_thread = nil
|
|
28
|
+
remove_instance_variable(:@split_step_definition_digest) if
|
|
29
|
+
instance_variable_defined?(:@split_step_definition_digest)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def finalize_split_step_preparation!(transition, transition_name, key, adapter, persistence_ttl)
|
|
34
|
+
transaction_identity = TransactionIdentity.capture(adapter)
|
|
35
|
+
transition_signature = TransitionContract.capture(transition)
|
|
36
|
+
@split_step_mutex.synchronize do
|
|
37
|
+
unless active_split_step_preparation_claim?
|
|
38
|
+
raise WorkflowError, "the split-step preparation claim is no longer active"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@split_step_phase = :preparing
|
|
42
|
+
@split_step_transition_name = transition_name
|
|
43
|
+
@split_step_transition = transition
|
|
44
|
+
@split_step_transition_signature = transition_signature
|
|
45
|
+
@split_step_origin_state = @state
|
|
46
|
+
@split_step_token = SecureRandom.uuid.freeze
|
|
47
|
+
detach_split_step_execution_state!
|
|
48
|
+
target_writer = PreparationClaim.instance_method(:assign_split_step_persistence_target!)
|
|
49
|
+
target_writer.bind_call(self, key, adapter, persistence_ttl, transaction_identity)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def assign_split_step_persistence_target!(key, adapter, persistence_ttl, transaction_identity)
|
|
54
|
+
@split_step_persistence_key = key
|
|
55
|
+
@split_step_adapter = adapter
|
|
56
|
+
@split_step_persistence_ttl = persistence_ttl
|
|
57
|
+
@split_step_transaction_identity = transaction_identity
|
|
58
|
+
@split_step_dispatch_started = false
|
|
59
|
+
@persistence_key = key
|
|
60
|
+
@split_step_persist_permit = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def active_split_step_preparation_claim?
|
|
64
|
+
@split_step_phase == :claiming_preparation &&
|
|
65
|
+
@split_step_preparation_thread.equal?(Thread.current)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def mark_split_step_prepared!(_adapter)
|
|
69
|
+
@split_step_mutex.synchronize do
|
|
70
|
+
phase = @split_step_transaction_identity ? :prepared_uncommitted : :prepared
|
|
71
|
+
unless @split_step_pending_descriptor
|
|
72
|
+
raise WorkflowError, "split-step preparation descriptor was not captured"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@split_step_prepared_descriptor = @split_step_pending_descriptor
|
|
76
|
+
@split_step_pending_descriptor = nil
|
|
77
|
+
@split_step_phase = phase
|
|
78
|
+
@split_step_preparation_thread = nil
|
|
79
|
+
@split_step_persist_permit = false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_split_step_prepared_descriptor(persistence_version)
|
|
84
|
+
PreparedStep.new(
|
|
85
|
+
token: @split_step_token,
|
|
86
|
+
transition: @split_step_transition_name.to_s,
|
|
87
|
+
from: @split_step_origin_state.to_s,
|
|
88
|
+
persistence_key: @split_step_persistence_key,
|
|
89
|
+
persistence_version: persistence_version,
|
|
90
|
+
step_number: @step_count + 1,
|
|
91
|
+
preparation_digest: @split_step_preparation_digest,
|
|
92
|
+
definition_digest: effective_definition_digest
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def claim_split_step_confirmation!
|
|
97
|
+
@split_step_mutex.synchronize do
|
|
98
|
+
unless @split_step_phase == :prepared_uncommitted
|
|
99
|
+
raise WorkflowError, "no uncommitted split-step preparation is awaiting confirmation"
|
|
100
|
+
end
|
|
101
|
+
if @split_step_adapter.transaction_open?
|
|
102
|
+
raise WorkflowError, "the split-step preparation transaction is still open"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@split_step_phase = :confirming_preparation
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def restore_unconfirmed_preparation!
|
|
110
|
+
@split_step_mutex.synchronize do
|
|
111
|
+
@split_step_phase = :prepared_uncommitted if @split_step_phase == :confirming_preparation
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module SplitStepPersistence
|
|
6
|
+
module PreparationPayload
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def capture_split_step_preparation_payload!(payload, next_version)
|
|
10
|
+
validate_split_step_marker!(payload, expected: true)
|
|
11
|
+
prepared_payload = split_step_preparation_payload(payload)
|
|
12
|
+
@split_step_preparation_payload = prepared_payload
|
|
13
|
+
@split_step_preparation_digest = CanonicalPayloadDigest.call(prepared_payload)
|
|
14
|
+
descriptor_builder = PreparationClaim.instance_method(:build_split_step_prepared_descriptor)
|
|
15
|
+
@split_step_pending_descriptor = descriptor_builder.bind_call(self, next_version)
|
|
16
|
+
prepared_payload
|
|
17
|
+
rescue StandardError
|
|
18
|
+
resetter = PreparationRecovery.instance_method(:reset_failed_split_step_preparation!)
|
|
19
|
+
resetter.bind_call(self)
|
|
20
|
+
raise
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module SplitStepPersistence
|
|
6
|
+
module PreparationRecovery
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def persist_claimed_split_step_preparation!(resolved_key, store)
|
|
10
|
+
persist!(resolved_key, adapter: store)
|
|
11
|
+
mark_split_step_prepared!(store)
|
|
12
|
+
rescue PersistenceVersionConflict
|
|
13
|
+
handle_split_step_preparation_conflict!
|
|
14
|
+
raise
|
|
15
|
+
rescue StandardError
|
|
16
|
+
if @split_step_dispatch_started
|
|
17
|
+
mark_split_step_preparation_unknown!
|
|
18
|
+
else
|
|
19
|
+
reset_failed_split_step_preparation!
|
|
20
|
+
end
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reset_failed_split_step_preparation!
|
|
25
|
+
return unless @split_step_phase == :preparing
|
|
26
|
+
|
|
27
|
+
clear_step_in_progress!
|
|
28
|
+
clear_split_step_boundary!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_split_step_preparation_conflict!
|
|
32
|
+
payload = @split_step_adapter.fetch(@split_step_persistence_key)
|
|
33
|
+
if persisted_split_step_payload?(payload, @split_step_preparation_payload)
|
|
34
|
+
mark_split_step_preparation_unknown!
|
|
35
|
+
else
|
|
36
|
+
reset_failed_split_step_preparation!
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError
|
|
39
|
+
mark_split_step_preparation_unknown!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def mark_split_step_preparation_unknown!
|
|
43
|
+
@split_step_mutex.synchronize do
|
|
44
|
+
if @split_step_phase == :preparing
|
|
45
|
+
@split_step_phase = :preparation_unknown
|
|
46
|
+
@split_step_prepared_descriptor = nil
|
|
47
|
+
@split_step_transaction_identity = nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module SplitStepPersistence
|
|
6
|
+
module Recovery
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def recover_prepared_step(recovery, adapter)
|
|
10
|
+
decision = validate_prepared_step_recovery!(recovery)
|
|
11
|
+
descriptor = decision.prepared_step
|
|
12
|
+
store = persistence_adapter!(adapter)
|
|
13
|
+
ensure_strict_split_step_persistence!
|
|
14
|
+
validate_split_step_adapter!(store)
|
|
15
|
+
ensure_recovery_outside_transaction!(store)
|
|
16
|
+
payload = fetch_recovery_payload!(store, descriptor.persistence_key)
|
|
17
|
+
document = validate_recovery_payload!(payload, decision, store)
|
|
18
|
+
restore_state(document, allow_step_in_progress: true)
|
|
19
|
+
attach_recovered_split_step!(descriptor, payload, store, dispatch_claim: decision.dispatch_claim)
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def validate_prepared_step_recovery!(recovery)
|
|
24
|
+
return recovery if recovery.is_a?(PreparedStepRecovery)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError, "recovery must be a Smith::Workflow::PreparedStepRecovery"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ensure_recovery_outside_transaction!(adapter)
|
|
30
|
+
return unless Smith::PersistenceAdapters.supports?(adapter, :transaction_open?)
|
|
31
|
+
return unless adapter.transaction_open?
|
|
32
|
+
|
|
33
|
+
raise WorkflowError, "prepared-step recovery requires committed host authority outside a transaction"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch_recovery_payload!(adapter, key)
|
|
37
|
+
payload = adapter.fetch(key)
|
|
38
|
+
return payload if payload.is_a?(String)
|
|
39
|
+
|
|
40
|
+
raise WorkflowError, "the persisted split-step preparation is not available"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_recovery_payload!(payload, decision, adapter)
|
|
44
|
+
if payload.bytesize > CanonicalPayloadDigest::MAX_BYTES
|
|
45
|
+
raise WorkflowError,
|
|
46
|
+
"persisted split-step preparation exceeds maximum bytes #{CanonicalPayloadDigest::MAX_BYTES}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
document = JSON.parse(payload)
|
|
50
|
+
raise WorkflowError, "the persisted split-step preparation must be an object" unless document.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
validate_recovery_definition!(document, decision.prepared_step)
|
|
53
|
+
validate_recovery_identity!(document, decision, adapter)
|
|
54
|
+
validate_recovery_digest!(payload, decision)
|
|
55
|
+
document
|
|
56
|
+
rescue JSON::ParserError, TypeError
|
|
57
|
+
raise WorkflowError, "the persisted split-step preparation is invalid"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_recovery_definition!(document, descriptor)
|
|
61
|
+
current_digest = self.class.definition_digest
|
|
62
|
+
return if recovery_digests_match?(document, descriptor, current_digest) &&
|
|
63
|
+
recovery_class_matches?(document) && recovery_schema_matches?(document)
|
|
64
|
+
|
|
65
|
+
raise WorkflowError, "the prepared step does not match the current workflow definition"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def recovery_digests_match?(document, descriptor, current_digest)
|
|
69
|
+
current_digest &&
|
|
70
|
+
descriptor.definition_digest == current_digest &&
|
|
71
|
+
document["definition_digest"] == current_digest
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def recovery_class_matches?(document)
|
|
75
|
+
!self.class.name.to_s.empty? && document["class"] == self.class.name
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def recovery_schema_matches?(document)
|
|
79
|
+
document["schema_version"] == self.class.persistence_schema_version
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_recovery_identity!(document, decision, adapter)
|
|
83
|
+
descriptor = decision.prepared_step
|
|
84
|
+
return if recovery_marker_matches?(document, decision, adapter) &&
|
|
85
|
+
recovery_position_matches?(document, descriptor)
|
|
86
|
+
|
|
87
|
+
raise WorkflowError, "the persisted split-step preparation does not match the recovery descriptor"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def recovery_marker_matches?(document, decision, adapter)
|
|
91
|
+
descriptor = decision.prepared_step
|
|
92
|
+
document["step_in_progress"] == true &&
|
|
93
|
+
recovery_phase_matches?(document, decision.dispatch_claim) &&
|
|
94
|
+
document["split_step_persistence_identity"] == adapter.persistence_identity &&
|
|
95
|
+
document["split_step_token"] == descriptor.token &&
|
|
96
|
+
document["persistence_key"] == descriptor.persistence_key &&
|
|
97
|
+
document["persistence_version"] == descriptor.persistence_version
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def recovery_phase_matches?(document, dispatch_claim)
|
|
101
|
+
if dispatch_claim
|
|
102
|
+
document["split_step_phase"] == "dispatching" &&
|
|
103
|
+
document["split_step_dispatch_token"] == dispatch_claim.token
|
|
104
|
+
else
|
|
105
|
+
document["split_step_phase"] == "prepared" && !document.key?("split_step_dispatch_token")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def recovery_position_matches?(document, descriptor)
|
|
110
|
+
step_count = document["step_count"]
|
|
111
|
+
step_count.is_a?(Integer) &&
|
|
112
|
+
step_count + 1 == descriptor.step_number &&
|
|
113
|
+
document["state"].to_s == descriptor.from
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_recovery_digest!(payload, decision)
|
|
117
|
+
expected = decision.dispatch_claim&.dispatch_digest || decision.prepared_step.preparation_digest
|
|
118
|
+
return if CanonicalPayloadDigest.call(payload) == expected
|
|
119
|
+
|
|
120
|
+
raise WorkflowError, "the persisted split-step recovery payload digest does not match"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module SplitStepPersistence
|
|
6
|
+
module RecoveryBoundary
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def attach_recovered_split_step!(descriptor, payload, adapter, dispatch_claim:)
|
|
10
|
+
transition = pending_split_step_transition
|
|
11
|
+
transition_name = @next_transition_name || transition&.name
|
|
12
|
+
unless transition && transition_name.to_s == descriptor.transition && @state.to_s == descriptor.from
|
|
13
|
+
raise WorkflowError, "the recovered transition does not match the current workflow position"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
detach_split_step_execution_state!
|
|
17
|
+
install_recovered_split_step!(descriptor, payload, adapter, transition, dispatch_claim)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def install_recovered_split_step!(descriptor, payload, adapter, transition, dispatch_claim)
|
|
21
|
+
signature = TransitionContract.capture(transition)
|
|
22
|
+
@split_step_mutex.synchronize do
|
|
23
|
+
install_recovered_transition!(transition, signature)
|
|
24
|
+
install_recovered_persistence!(descriptor, payload, adapter)
|
|
25
|
+
install_recovered_dispatch!(dispatch_claim)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def install_recovered_transition!(transition, signature)
|
|
30
|
+
@split_step_transition_name = transition.name
|
|
31
|
+
@split_step_transition = transition
|
|
32
|
+
@split_step_transition_signature = signature
|
|
33
|
+
@split_step_origin_state = @state
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def install_recovered_persistence!(descriptor, payload, adapter)
|
|
37
|
+
@split_step_token = descriptor.token
|
|
38
|
+
@split_step_prepared_descriptor = descriptor
|
|
39
|
+
@split_step_persistence_key = descriptor.persistence_key
|
|
40
|
+
@split_step_adapter = adapter
|
|
41
|
+
@split_step_persistence_ttl = nil
|
|
42
|
+
@split_step_preparation_payload = payload.dup.freeze
|
|
43
|
+
@split_step_preparation_digest = descriptor.preparation_digest
|
|
44
|
+
@split_step_definition_digest = descriptor.definition_digest
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def install_recovered_dispatch!(dispatch_claim)
|
|
48
|
+
@split_step_dispatch_started = false
|
|
49
|
+
@split_step_dispatch_token = dispatch_claim&.token
|
|
50
|
+
@split_step_dispatch_descriptor = dispatch_claim
|
|
51
|
+
@split_step_phase = dispatch_claim ? :dispatch_claimed : :prepared
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module SplitStepPersistence
|
|
6
|
+
module RecoveryClassMethods
|
|
7
|
+
def recover_prepared_step(recovery, adapter: Smith.persistence_adapter)
|
|
8
|
+
seal_definition_identity!
|
|
9
|
+
allocate.send(:recover_prepared_step, recovery, adapter)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
module SplitStepPersistence
|
|
8
|
+
class ReplaceExactSignature
|
|
9
|
+
extend Dry::Initializer
|
|
10
|
+
|
|
11
|
+
CONTRACT_KEYWORDS = %i[expected_payload ttl].freeze
|
|
12
|
+
|
|
13
|
+
param :callable
|
|
14
|
+
|
|
15
|
+
def valid? = accepts_two_values? && accepts_keywords?
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def parameters = callable.parameters
|
|
20
|
+
|
|
21
|
+
def accepts_two_values?
|
|
22
|
+
required = parameters.count { |kind, _| kind == :req }
|
|
23
|
+
positional = parameters.count { |kind, _| %i[req opt].include?(kind) }
|
|
24
|
+
rest = parameters.any? { |kind, _| kind == :rest }
|
|
25
|
+
required <= 2 && (positional >= 2 || rest)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def accepts_keywords?
|
|
29
|
+
required_keywords_supported? && contract_keywords_accepted?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def required_keywords_supported?
|
|
33
|
+
required = parameters.filter_map { |kind, name| name if kind == :keyreq }
|
|
34
|
+
(required - CONTRACT_KEYWORDS).empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def contract_keywords_accepted?
|
|
38
|
+
return true if parameters.any? { |kind, _| kind == :keyrest }
|
|
39
|
+
|
|
40
|
+
keywords = parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
|
|
41
|
+
CONTRACT_KEYWORDS.all? { |name| keywords.include?(name) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "replace_exact_signature"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
module SplitStepPersistence
|
|
8
|
+
module RestartSafeAdapter
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def validate_restart_safe_adapter!(adapter)
|
|
12
|
+
return unless restart_safe_split_step?
|
|
13
|
+
|
|
14
|
+
ensure_replace_exact_capability!(adapter)
|
|
15
|
+
ensure_replace_exact_signature!(adapter)
|
|
16
|
+
ensure_persistence_identity!(adapter)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ensure_replace_exact_capability!(adapter)
|
|
20
|
+
return if Smith::PersistenceAdapters.supports?(adapter, :replace_exact)
|
|
21
|
+
|
|
22
|
+
raise WorkflowError, "restart-safe split-step persistence requires replace_exact"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ensure_replace_exact_signature!(adapter)
|
|
26
|
+
return if ReplaceExactSignature.new(adapter.method(:replace_exact)).valid?
|
|
27
|
+
|
|
28
|
+
raise WorkflowError,
|
|
29
|
+
"restart-safe split-step persistence requires replace_exact(key, payload, expected_payload:, ttl:)"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_persistence_identity!(adapter)
|
|
33
|
+
identity = adapter.persistence_identity if
|
|
34
|
+
Smith::PersistenceAdapters.supports?(adapter, :persistence_identity)
|
|
35
|
+
return if identity.is_a?(String) && !identity.empty? && identity.bytesize <= 256
|
|
36
|
+
|
|
37
|
+
raise WorkflowError, "restart-safe split-step persistence requires a bounded persistence_identity"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|