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
data/lib/smith/errors.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "error"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
# Classification surface for host retry policies. Smith owns the
|
|
5
7
|
# answer to "should the workflow attempt be retried?" so consumers
|
|
@@ -35,6 +37,7 @@ module Smith
|
|
|
35
37
|
class DeadlineExceeded < Error; end
|
|
36
38
|
class MaxTransitionsExceeded < Error; end
|
|
37
39
|
class GuardrailFailed < Error; end
|
|
40
|
+
|
|
38
41
|
class ToolGuardrailFailed < Error
|
|
39
42
|
attr_reader :retryable
|
|
40
43
|
|
|
@@ -43,8 +46,10 @@ module Smith
|
|
|
43
46
|
super(message)
|
|
44
47
|
end
|
|
45
48
|
end
|
|
49
|
+
|
|
46
50
|
class ToolPolicyDenied < Error; end
|
|
47
51
|
class AgentError < Error; end
|
|
52
|
+
|
|
48
53
|
class BlankAgentOutputError < AgentError
|
|
49
54
|
attr_reader :agent_name, :model_used
|
|
50
55
|
|
|
@@ -60,6 +65,7 @@ module Smith
|
|
|
60
65
|
super(detail)
|
|
61
66
|
end
|
|
62
67
|
end
|
|
68
|
+
|
|
63
69
|
class WorkflowError < Error; end
|
|
64
70
|
|
|
65
71
|
class DeterministicStepFailure < WorkflowError
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class ExponentialBackoff
|
|
5
|
+
MAX_SLEEP_INTERVAL_SECONDS = 2_147_483_647.0
|
|
6
|
+
MAX_RANDOM_FACTOR = 1.0.prev_float
|
|
7
|
+
|
|
8
|
+
attr_reader :attempts, :base_delay, :max_delay, :jitter
|
|
9
|
+
|
|
10
|
+
def initialize(attempts:, base_delay:, max_delay:, jitter:, delay_label: "base_delay")
|
|
11
|
+
@attempt_limit = Smith.config.retry_attempt_limit
|
|
12
|
+
@attempts = validate_attempts!(attempts)
|
|
13
|
+
@base_delay = normalize_delay!(base_delay, delay_label)
|
|
14
|
+
@max_delay = normalize_optional_delay!(max_delay, "max_delay")
|
|
15
|
+
@jitter = normalize_delay!(jitter, "jitter")
|
|
16
|
+
validate_finite_schedule!
|
|
17
|
+
freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def delay(failed_attempt, random: Kernel.method(:rand))
|
|
21
|
+
validate_failed_attempt!(failed_attempt)
|
|
22
|
+
|
|
23
|
+
base = exponential_delay(failed_attempt - 1)
|
|
24
|
+
return base if jitter.zero? || max_delay == base
|
|
25
|
+
|
|
26
|
+
add_jitter(base, random.call)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def validate_attempts!(value)
|
|
32
|
+
raise ArgumentError, "attempts must be a positive integer" unless value.is_a?(Integer) && value.positive?
|
|
33
|
+
raise ArgumentError, "attempts must not exceed #{@attempt_limit}" if value > @attempt_limit
|
|
34
|
+
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize_optional_delay!(value, label)
|
|
39
|
+
return if value.nil?
|
|
40
|
+
|
|
41
|
+
normalize_delay!(value, label)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize_delay!(value, label)
|
|
45
|
+
numeric = Float(value)
|
|
46
|
+
return numeric if numeric.finite? && numeric >= 0.0
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, "#{label} must be finite and non-negative"
|
|
49
|
+
rescue TypeError, ArgumentError, RangeError
|
|
50
|
+
raise ArgumentError, "#{label} must be finite and non-negative"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_finite_schedule!
|
|
54
|
+
return if attempts == 1
|
|
55
|
+
|
|
56
|
+
largest_delay = maximum_delay(attempts - 2)
|
|
57
|
+
return if largest_delay <= MAX_SLEEP_INTERVAL_SECONDS
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "retry delay exceeds supported sleep interval #{MAX_SLEEP_INTERVAL_SECONDS.to_i} seconds"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_failed_attempt!(failed_attempt)
|
|
63
|
+
return if failed_attempt.is_a?(Integer) && failed_attempt.positive? && failed_attempt < attempts
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "failed_attempt must identify a retryable attempt"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def exponential_delay(exponent)
|
|
69
|
+
return 0.0 if base_delay.zero? || max_delay&.zero?
|
|
70
|
+
return uncapped_exponential_delay(exponent) unless max_delay
|
|
71
|
+
|
|
72
|
+
capped_exponential_delay(exponent)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def capped_exponential_delay(exponent)
|
|
76
|
+
base_fraction, base_exponent = Math.frexp(base_delay)
|
|
77
|
+
cap_fraction, cap_exponent = Math.frexp(max_delay)
|
|
78
|
+
scaled_exponent = base_exponent + exponent
|
|
79
|
+
|
|
80
|
+
return max_delay if scaled_exponent > cap_exponent
|
|
81
|
+
return max_delay if scaled_exponent == cap_exponent && base_fraction >= cap_fraction
|
|
82
|
+
|
|
83
|
+
[Math.ldexp(base_delay, exponent), max_delay].min
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def uncapped_exponential_delay(exponent)
|
|
87
|
+
_fraction, base_exponent = Math.frexp(base_delay)
|
|
88
|
+
if base_exponent + exponent > Float::MAX_EXP
|
|
89
|
+
raise ArgumentError, "retry schedule exceeds the finite numeric range"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Math.ldexp(base_delay, exponent).tap do |value|
|
|
93
|
+
raise ArgumentError, "retry schedule exceeds the finite numeric range" unless value.finite?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def add_jitter(base, random_value)
|
|
98
|
+
factor = normalize_random_factor!(random_value)
|
|
99
|
+
sampled = jitter * factor
|
|
100
|
+
return max_delay if max_delay && sampled >= max_delay - base
|
|
101
|
+
|
|
102
|
+
base + sampled
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize_random_factor!(value)
|
|
106
|
+
factor = Float(value)
|
|
107
|
+
unless factor.finite? && factor >= 0.0 && factor < 1.0
|
|
108
|
+
raise ArgumentError, "random value must be finite and within 0.0...1.0"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
factor
|
|
112
|
+
rescue TypeError, ArgumentError, RangeError
|
|
113
|
+
raise ArgumentError, "random value must be finite and within 0.0...1.0"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def maximum_delay(exponent)
|
|
117
|
+
base = exponential_delay(exponent)
|
|
118
|
+
return base if jitter.zero? || max_delay == base
|
|
119
|
+
|
|
120
|
+
sampled = jitter * MAX_RANDOM_FACTOR
|
|
121
|
+
return max_delay if max_delay && sampled >= max_delay - base
|
|
122
|
+
|
|
123
|
+
(base + sampled).tap do |value|
|
|
124
|
+
raise ArgumentError, "retry schedule exceeds the finite numeric range" unless value.finite?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module PersistenceAdapters
|
|
5
|
+
module ActiveRecordConnectionErrors
|
|
6
|
+
NAMES = %w[
|
|
7
|
+
ActiveRecord::ConnectionNotEstablished
|
|
8
|
+
ActiveRecord::ConnectionFailed
|
|
9
|
+
ActiveRecord::AdapterTimeout
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def classes
|
|
15
|
+
NAMES.filter_map do |name|
|
|
16
|
+
Object.const_get(name)
|
|
17
|
+
rescue NameError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module PersistenceAdapters
|
|
7
|
+
class ActiveRecordExactPredicate
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :model
|
|
11
|
+
option :column
|
|
12
|
+
|
|
13
|
+
def call(scope, expected_payload)
|
|
14
|
+
scope.where(predicate, expected_payload)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def predicate
|
|
20
|
+
case connection.adapter_name.downcase
|
|
21
|
+
when /postgres/
|
|
22
|
+
"convert_to(#{qualified_column}, 'UTF8') = convert_to(?, 'UTF8')"
|
|
23
|
+
when /sqlite/
|
|
24
|
+
"CAST(#{qualified_column} AS BLOB) = CAST(? AS BLOB)"
|
|
25
|
+
when /mysql|trilogy/
|
|
26
|
+
"BINARY #{qualified_column} = BINARY ?"
|
|
27
|
+
else
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"ActiveRecordStore#replace_exact does not support byte-exact payload comparison for " \
|
|
30
|
+
"#{connection.adapter_name.inspect}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def qualified_column
|
|
35
|
+
table = connection.quote_table_name(model.table_name)
|
|
36
|
+
attribute = connection.quote_column_name(column)
|
|
37
|
+
"#{table}.#{attribute}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def connection = model.connection
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module PersistenceAdapters
|
|
5
|
+
module ActiveRecordExactStore
|
|
6
|
+
attr_reader :persistence_identity
|
|
7
|
+
|
|
8
|
+
def replace_exact(key, payload, expected_payload:, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
9
|
+
ensure_version_column!
|
|
10
|
+
ensure_locking_configuration!
|
|
11
|
+
model = model_class
|
|
12
|
+
ensure_exact_schema!(model)
|
|
13
|
+
ActiveRecordExactWrite.new(
|
|
14
|
+
model: model,
|
|
15
|
+
key_column: @key_column,
|
|
16
|
+
payload_column: @payload_column,
|
|
17
|
+
version_column: @version_column,
|
|
18
|
+
key: key,
|
|
19
|
+
payload: payload,
|
|
20
|
+
expected_payload: expected_payload
|
|
21
|
+
).call
|
|
22
|
+
rescue *ActiveRecordConnectionErrors.classes => e
|
|
23
|
+
raise Smith::PersistenceIOError.new(operation: :replace_exact, cause: e)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def ensure_exact_schema!(model)
|
|
29
|
+
ensure_exact_payload_column!(model)
|
|
30
|
+
ensure_exact_key_uniqueness!(model)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ensure_exact_payload_column!(model)
|
|
34
|
+
column = model.columns_hash.fetch(@payload_column.to_s)
|
|
35
|
+
return if %i[string text].include?(column.type)
|
|
36
|
+
|
|
37
|
+
raise ArgumentError,
|
|
38
|
+
"ActiveRecordStore#replace_exact requires a text or string payload column on " \
|
|
39
|
+
"#{model.table_name}.#{@payload_column}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ensure_exact_key_uniqueness!(model)
|
|
43
|
+
connection = model.connection
|
|
44
|
+
schema_cache = connection.schema_cache
|
|
45
|
+
table_name = model.table_name
|
|
46
|
+
key = @key_column.to_s
|
|
47
|
+
return if schema_cache.primary_keys(table_name).to_s == key
|
|
48
|
+
return if unconditional_unique_key_index?(schema_cache, table_name, key)
|
|
49
|
+
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"ActiveRecordStore#replace_exact requires a unique database index on " \
|
|
52
|
+
"#{table_name}.#{@key_column}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def unconditional_unique_key_index?(schema_cache, table_name, key)
|
|
56
|
+
schema_cache.indexes(table_name).any? do |index|
|
|
57
|
+
index.unique && Array(index.columns).map(&:to_s) == [key] && index.where.to_s.empty?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "active_record_exact_predicate"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
module PersistenceAdapters
|
|
9
|
+
class ActiveRecordExactWrite
|
|
10
|
+
extend Dry::Initializer
|
|
11
|
+
|
|
12
|
+
option :model
|
|
13
|
+
option :key_column
|
|
14
|
+
option :payload_column
|
|
15
|
+
option :version_column
|
|
16
|
+
option :key
|
|
17
|
+
option :payload
|
|
18
|
+
option :expected_payload
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
updated = exact_scope.update_all(update_attributes)
|
|
22
|
+
return payload if updated == 1
|
|
23
|
+
|
|
24
|
+
raise PersistencePayloadConflict.new(key:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def exact_scope
|
|
30
|
+
scope = model.where(key_column => key)
|
|
31
|
+
ActiveRecordExactPredicate.new(model:, column: payload_column).call(scope, expected_payload)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def update_attributes
|
|
35
|
+
lock = model.arel_table[version_column]
|
|
36
|
+
{ payload_column => payload, version_column => lock + 1 }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module PersistenceAdapters
|
|
5
|
+
module ActiveRecordInitialWrite
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(model:, key_column:, payload_column:, key:, payload:)
|
|
9
|
+
candidate = nil
|
|
10
|
+
record = model.create_or_find_by!(key_column => key) do |new_record|
|
|
11
|
+
candidate = new_record
|
|
12
|
+
new_record.public_send(:"#{payload_column}=", payload)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
created_record?(record, candidate)
|
|
16
|
+
rescue ::ActiveRecord::RecordInvalid => e
|
|
17
|
+
raise unless key_collision?(e.record, candidate, model, key_column, key)
|
|
18
|
+
|
|
19
|
+
false
|
|
20
|
+
rescue ::ActiveRecord::RecordNotFound => e
|
|
21
|
+
raise e.cause if e.cause.is_a?(::ActiveRecord::RecordNotUnique)
|
|
22
|
+
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def created_record?(record, candidate)
|
|
27
|
+
return false unless record.equal?(candidate)
|
|
28
|
+
return true if record.persisted?
|
|
29
|
+
|
|
30
|
+
raise ::ActiveRecord::RecordNotSaved, "Active Record rolled back the workflow-state insert"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def key_collision?(record, candidate, model, key_column, key)
|
|
34
|
+
record.equal?(candidate) &&
|
|
35
|
+
record.errors.of_kind?(key_column, :taken) &&
|
|
36
|
+
model.exists?(key_column => key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private_class_method :created_record?, :key_collision?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -3,35 +3,24 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
module PersistenceAdapters
|
|
5
5
|
class ActiveRecordStore
|
|
6
|
+
include ActiveRecordExactStore
|
|
7
|
+
|
|
6
8
|
# AR transient errors resolved via class-name guard so Smith
|
|
7
9
|
# doesn't require activerecord at load time. Hosts that use this
|
|
8
10
|
# adapter already have activerecord in their dep tree.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def self.transient_errors
|
|
16
|
-
TRANSIENT_ERROR_NAMES.filter_map do |name|
|
|
17
|
-
Object.const_get(name)
|
|
18
|
-
rescue NameError
|
|
19
|
-
nil
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def initialize(model:, key_column: :key, payload_column: :payload, version_column: :lock_version)
|
|
24
|
-
@model_source = model
|
|
25
|
-
@key_column = key_column
|
|
26
|
-
@payload_column = payload_column
|
|
27
|
-
@version_column = version_column
|
|
11
|
+
def initialize(model:, key_column: :key, payload_column: :payload, version_column: :lock_version, identity: nil)
|
|
12
|
+
@model_source = model.is_a?(String) ? model.dup.freeze : model
|
|
13
|
+
@key_column = normalize_column(key_column)
|
|
14
|
+
@payload_column = normalize_column(payload_column)
|
|
15
|
+
@version_column = normalize_column(version_column)
|
|
16
|
+
@persistence_identity = identity.to_s.dup.freeze if identity
|
|
28
17
|
end
|
|
29
18
|
|
|
30
19
|
def store(key, payload, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
31
20
|
# TTL is deferred for ActiveRecordStore — would require an
|
|
32
21
|
# `expires_at` column + a periodic sweeper job. Ignored here;
|
|
33
22
|
# documented as a known limitation.
|
|
34
|
-
Retry.with_retries(operation: :store, transient:
|
|
23
|
+
Retry.with_retries(operation: :store, transient: ActiveRecordConnectionErrors.classes) do
|
|
35
24
|
record = model_class.find_or_initialize_by(@key_column => key)
|
|
36
25
|
record.public_send(:"#{@payload_column}=", payload)
|
|
37
26
|
record.save!
|
|
@@ -39,60 +28,114 @@ module Smith
|
|
|
39
28
|
end
|
|
40
29
|
|
|
41
30
|
def fetch(key)
|
|
42
|
-
Retry.with_retries(operation: :fetch, transient:
|
|
31
|
+
Retry.with_retries(operation: :fetch, transient: ActiveRecordConnectionErrors.classes) do
|
|
43
32
|
model_class.find_by(@key_column => key)&.public_send(@payload_column)
|
|
44
33
|
end
|
|
45
34
|
end
|
|
46
35
|
|
|
47
36
|
def delete(key)
|
|
48
|
-
Retry.with_retries(operation: :delete, transient:
|
|
37
|
+
Retry.with_retries(operation: :delete, transient: ActiveRecordConnectionErrors.classes) do
|
|
49
38
|
model_class.where(@key_column => key).delete_all
|
|
50
39
|
end
|
|
51
40
|
end
|
|
52
41
|
|
|
42
|
+
def transaction_open? = model_class.connection.transaction_open?
|
|
43
|
+
|
|
44
|
+
def transaction_identity = model_class.respond_to?(:current_transaction) && model_class.current_transaction.uuid
|
|
45
|
+
|
|
53
46
|
# Optimistic locking via Rails' built-in optimistic locking on the
|
|
54
47
|
# `lock_version` column. Requires the AR model to have a
|
|
55
48
|
# `lock_version` (or configured) integer column with default 0.
|
|
56
49
|
# If absent, raises ArgumentError directing the host to migrate.
|
|
57
50
|
def store_versioned(key, payload, expected_version:, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"ActiveRecordStore#store_versioned requires a #{@version_column} column on " \
|
|
61
|
-
"#{model_class.name}. Add via: " \
|
|
62
|
-
"add_column :#{model_class.table_name}, :#{@version_column}, :integer, default: 0"
|
|
63
|
-
end
|
|
51
|
+
ensure_version_column!
|
|
52
|
+
ensure_locking_configuration!
|
|
64
53
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
54
|
+
write_versioned(key, payload, expected_version)
|
|
55
|
+
rescue *ActiveRecordConnectionErrors.classes => e
|
|
56
|
+
raise Smith::PersistenceIOError.new(operation: :store_versioned, cause: e)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def normalize_column(column)
|
|
62
|
+
column.is_a?(String) ? column.dup.freeze : column
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ensure_version_column!
|
|
66
|
+
return if model_class.column_names.include?(@version_column.to_s)
|
|
76
67
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"ActiveRecordStore#store_versioned requires a #{@version_column} column on " \
|
|
70
|
+
"#{model_class.name}. Add via: " \
|
|
71
|
+
"add_column :#{model_class.table_name}, :#{@version_column}, :integer, default: 0"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def ensure_locking_configuration!
|
|
75
|
+
locking_column = model_class.locking_column if model_class.respond_to?(:locking_column)
|
|
76
|
+
locking_enabled = model_class.locking_enabled? if model_class.respond_to?(:locking_enabled?)
|
|
77
|
+
return if locking_enabled && locking_column.to_s == @version_column.to_s
|
|
78
|
+
|
|
79
|
+
raise ArgumentError,
|
|
80
|
+
"ActiveRecordStore#store_versioned requires #{model_class.name}.locking_column " \
|
|
81
|
+
"to be #{@version_column.inspect} with optimistic locking enabled"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write_versioned(key, payload, expected_version)
|
|
85
|
+
record = model_class.find_by(@key_column => key)
|
|
86
|
+
unless record
|
|
87
|
+
VersionExpectation.validate_missing!(key, expected_version)
|
|
88
|
+
return create_versioned_record(key, payload, expected_version)
|
|
80
89
|
end
|
|
90
|
+
|
|
91
|
+
validate_payload_version!(record, key, expected_version)
|
|
92
|
+
record.public_send(:"#{@payload_column}=", payload)
|
|
93
|
+
record.save!
|
|
94
|
+
rescue ::ActiveRecord::StaleObjectError => e
|
|
95
|
+
raise unless e.record.equal?(record)
|
|
96
|
+
|
|
97
|
+
raise_concurrent_conflict(key, expected_version)
|
|
81
98
|
end
|
|
82
99
|
|
|
83
|
-
|
|
100
|
+
def create_versioned_record(key, payload, expected_version)
|
|
101
|
+
created = ActiveRecordInitialWrite.call(
|
|
102
|
+
model: model_class,
|
|
103
|
+
key_column: @key_column,
|
|
104
|
+
payload_column: @payload_column,
|
|
105
|
+
key: key,
|
|
106
|
+
payload: payload
|
|
107
|
+
)
|
|
108
|
+
return true if created
|
|
109
|
+
|
|
110
|
+
raise_concurrent_conflict(key, expected_version)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_payload_version!(record, key, expected_version)
|
|
114
|
+
current_version = payload_version(record)
|
|
115
|
+
return if current_version == expected_version
|
|
116
|
+
|
|
117
|
+
raise Smith::PersistenceVersionConflict.new(
|
|
118
|
+
key: key, expected: expected_version, actual: current_version
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def payload_version(record) = PayloadVersion.call(record.public_send(@payload_column))
|
|
123
|
+
|
|
124
|
+
def raise_concurrent_conflict(key, expected_version)
|
|
125
|
+
raise Smith::PersistenceVersionConflict.new(
|
|
126
|
+
key: key, expected: expected_version, actual: :concurrent
|
|
127
|
+
)
|
|
128
|
+
end
|
|
84
129
|
|
|
85
130
|
def model_class
|
|
86
|
-
@
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@model_source
|
|
92
|
-
end
|
|
93
|
-
rescue NameError => e
|
|
94
|
-
raise ArgumentError, "ActiveRecord model #{@model_source.inspect} could not be resolved: #{e.message}"
|
|
131
|
+
case @model_source
|
|
132
|
+
when String
|
|
133
|
+
Object.const_get(@model_source)
|
|
134
|
+
else
|
|
135
|
+
@model_source
|
|
95
136
|
end
|
|
137
|
+
rescue NameError => e
|
|
138
|
+
raise ArgumentError, "ActiveRecord model #{@model_source.inspect} could not be resolved: #{e.message}"
|
|
96
139
|
end
|
|
97
140
|
end
|
|
98
141
|
end
|
|
@@ -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,9 @@ module Smith
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
def transaction_open? = false
|
|
46
|
+
def transaction_identity = nil
|
|
47
|
+
|
|
45
48
|
def backend_name
|
|
46
49
|
backend.class.name || backend.class.to_s
|
|
47
50
|
end
|