smith-agents 0.4.5 → 0.6.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 +264 -0
- data/README.md +191 -4
- data/docs/CONFIGURATION.md +4 -1
- data/docs/PATTERNS.md +7 -0
- data/docs/PERSISTENCE.md +347 -25
- 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 +37 -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 -4
- data/lib/smith/error.rb +5 -0
- data/lib/smith/errors.rb +10 -0
- data/lib/smith/exponential_backoff.rb +128 -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_store.rb +7 -4
- data/lib/smith/persistence_adapters/cache_store.rb +1 -0
- data/lib/smith/persistence_adapters/memory.rb +16 -1
- 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 +28 -11
- data/lib/smith/persistence_adapters/retry.rb +22 -10
- data/lib/smith/persistence_adapters.rb +21 -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/composite/branch.rb +93 -0
- data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
- data/lib/smith/workflow/composite/branch_contract.rb +102 -0
- data/lib/smith/workflow/composite/branch_execution.rb +124 -0
- data/lib/smith/workflow/composite/branch_failure.rb +91 -0
- data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
- data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
- data/lib/smith/workflow/composite/budget_math.rb +51 -0
- data/lib/smith/workflow/composite/contract.rb +100 -0
- data/lib/smith/workflow/composite/effects.rb +140 -0
- data/lib/smith/workflow/composite/effects_application.rb +36 -0
- data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
- data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
- data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
- data/lib/smith/workflow/composite/enums.rb +27 -0
- data/lib/smith/workflow/composite/error.rb +54 -0
- data/lib/smith/workflow/composite/error_evidence.rb +61 -0
- data/lib/smith/workflow/composite/execution_contract.rb +73 -0
- data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
- data/lib/smith/workflow/composite/input.rb +53 -0
- data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
- data/lib/smith/workflow/composite/outcome_set.rb +23 -0
- data/lib/smith/workflow/composite/payload.rb +128 -0
- data/lib/smith/workflow/composite/payload_digest.rb +28 -0
- data/lib/smith/workflow/composite/plan.rb +130 -0
- data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
- data/lib/smith/workflow/composite/planner.rb +53 -0
- data/lib/smith/workflow/composite/preparation.rb +27 -0
- data/lib/smith/workflow/composite/reducer.rb +133 -0
- data/lib/smith/workflow/composite/reduction.rb +31 -0
- data/lib/smith/workflow/composite/value_budget.rb +90 -0
- data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
- data/lib/smith/workflow/deadline_enforcement.rb +22 -5
- 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 +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
- data/lib/smith/workflow/execution.rb +28 -30
- 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 +20 -19
- 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 +134 -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 +46 -14
- data/lib/smith/workflow/persistence.rb +37 -8
- data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
- 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 +101 -0
- data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
- data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
- data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
- data/lib/smith/workflow/process_local.rb +33 -0
- data/lib/smith/workflow/retry_execution.rb +20 -11
- data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
- 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 +3 -5
- data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
- data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -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 +20 -64
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
- data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
- data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
- data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
- data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
- data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
- 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/subclass_boundary.rb +66 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
- data/lib/smith/workflow/split_step_persistence.rb +50 -0
- data/lib/smith/workflow/step_completion.rb +31 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/string_snapshot.rb +25 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
- data/lib/smith/workflow/transition.rb +137 -133
- data/lib/smith/workflow/transition_actionability.rb +22 -0
- data/lib/smith/workflow.rb +64 -18
- data/lib/smith.rb +44 -2
- data/sig/smith.rbs +1 -0
- metadata +155 -19
|
@@ -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,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
|
|
@@ -3,14 +3,17 @@
|
|
|
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
|
-
def initialize(model:, key_column: :key, payload_column: :payload, version_column: :lock_version)
|
|
11
|
+
def initialize(model:, key_column: :key, payload_column: :payload, version_column: :lock_version, identity: nil)
|
|
10
12
|
@model_source = model.is_a?(String) ? model.dup.freeze : model
|
|
11
13
|
@key_column = normalize_column(key_column)
|
|
12
14
|
@payload_column = normalize_column(payload_column)
|
|
13
15
|
@version_column = normalize_column(version_column)
|
|
16
|
+
@persistence_identity = identity.to_s.dup.freeze if identity
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def store(key, payload, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
@@ -36,9 +39,9 @@ module Smith
|
|
|
36
39
|
end
|
|
37
40
|
end
|
|
38
41
|
|
|
39
|
-
def transaction_open?
|
|
40
|
-
|
|
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
|
|
42
45
|
|
|
43
46
|
# Optimistic locking via Rails' built-in optimistic locking on the
|
|
44
47
|
# `lock_version` column. Requires the AR model to have a
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "monitor"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module Smith
|
|
6
7
|
module PersistenceAdapters
|
|
@@ -15,7 +16,10 @@ module Smith
|
|
|
15
16
|
# Smith.config.persistence_adapter is nil AND Smith.config.test_mode
|
|
16
17
|
# is true (typically set in spec_helper.rb).
|
|
17
18
|
class Memory
|
|
18
|
-
|
|
19
|
+
attr_reader :persistence_identity
|
|
20
|
+
|
|
21
|
+
def initialize(identity: "memory:#{SecureRandom.uuid}")
|
|
22
|
+
@persistence_identity = identity.to_s.dup.freeze
|
|
19
23
|
@store = {}
|
|
20
24
|
@heartbeats = {}
|
|
21
25
|
@monitor = Monitor.new
|
|
@@ -44,6 +48,7 @@ module Smith
|
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
def transaction_open? = false
|
|
51
|
+
def transaction_identity = nil
|
|
47
52
|
|
|
48
53
|
def record_heartbeat(key, ttl: Smith.config.persistence_ttl)
|
|
49
54
|
@monitor.synchronize do
|
|
@@ -88,6 +93,16 @@ module Smith
|
|
|
88
93
|
end
|
|
89
94
|
end
|
|
90
95
|
|
|
96
|
+
def replace_exact(key, payload, expected_payload:, ttl: Smith.config.persistence_ttl)
|
|
97
|
+
@monitor.synchronize do
|
|
98
|
+
entry = live_entry(key)
|
|
99
|
+
raise PersistencePayloadConflict.new(key:) unless entry && entry[:payload] == expected_payload
|
|
100
|
+
|
|
101
|
+
@store[key] = entry_for(payload, ttl)
|
|
102
|
+
end
|
|
103
|
+
payload
|
|
104
|
+
end
|
|
105
|
+
|
|
91
106
|
def clear!
|
|
92
107
|
@monitor.synchronize { @store.clear }
|
|
93
108
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module PersistenceAdapters
|
|
5
|
+
module RedisClientAccess
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def client
|
|
9
|
+
return @client if instance_variable_defined?(:@client)
|
|
10
|
+
|
|
11
|
+
@client_resolution_mutex.synchronize do
|
|
12
|
+
return @client if instance_variable_defined?(:@client)
|
|
13
|
+
|
|
14
|
+
resolved = redis_client?(@redis_source) ? @redis_source : resolve_redis_source
|
|
15
|
+
raise ArgumentError, "Redis client is required" unless resolved
|
|
16
|
+
|
|
17
|
+
@client = resolved
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_redis_source
|
|
22
|
+
@redis_source.respond_to?(:call) ? @redis_source.call : @redis_source
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def redis_client?(candidate)
|
|
26
|
+
candidate.respond_to?(:get) && candidate.respond_to?(:set) && candidate.respond_to?(:del)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def without_reconnection
|
|
30
|
+
redis = client
|
|
31
|
+
return redis.without_reconnect { yield redis } if redis.respond_to?(:without_reconnect)
|
|
32
|
+
return redis.disable_reconnection { yield redis } if redis.respond_to?(:disable_reconnection)
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "Redis client must expose without_reconnect or disable_reconnection for CAS writes"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module PersistenceAdapters
|
|
7
|
+
class RedisExactWrite
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :client
|
|
11
|
+
option :key
|
|
12
|
+
option :storage_key
|
|
13
|
+
option :payload
|
|
14
|
+
option :expected_payload
|
|
15
|
+
option :ttl
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
result = client.watch(storage_key) do
|
|
19
|
+
validate_current!
|
|
20
|
+
enqueue_write
|
|
21
|
+
end
|
|
22
|
+
return payload unless result.nil?
|
|
23
|
+
|
|
24
|
+
raise PersistencePayloadConflict.new(key:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def validate_current!
|
|
30
|
+
return if client.get(storage_key) == expected_payload
|
|
31
|
+
|
|
32
|
+
client.unwatch
|
|
33
|
+
raise PersistencePayloadConflict.new(key:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enqueue_write
|
|
37
|
+
client.multi do |transaction|
|
|
38
|
+
options = ttl ? { ex: ttl } : {}
|
|
39
|
+
transaction.set(storage_key, payload, **options)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
4
|
|
|
5
|
+
require_relative "redis_client_access"
|
|
6
|
+
|
|
5
7
|
module Smith
|
|
6
8
|
module PersistenceAdapters
|
|
7
9
|
class RedisStore
|
|
10
|
+
include RedisClientAccess
|
|
11
|
+
|
|
8
12
|
# Redis transient errors — narrow list; non-transient errors
|
|
9
13
|
# (CommandError, etc.) propagate up immediately. Pattern matches
|
|
10
14
|
# Redis::BaseConnectionError if loaded (covers Connection/Timeout)
|
|
@@ -24,9 +28,13 @@ module Smith
|
|
|
24
28
|
end + [Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EPIPE]
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
attr_reader :persistence_identity
|
|
32
|
+
|
|
33
|
+
def initialize(redis:, namespace: "smith", identity: nil)
|
|
28
34
|
@redis_source = redis
|
|
29
35
|
@namespace = namespace.nil? ? nil : namespace.to_s.dup.freeze
|
|
36
|
+
@persistence_identity = identity.to_s.dup.freeze if identity
|
|
37
|
+
@client_resolution_mutex = Mutex.new
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
def store(key, payload, ttl: Smith.config.persistence_ttl)
|
|
@@ -52,6 +60,7 @@ module Smith
|
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
def transaction_open? = false
|
|
63
|
+
def transaction_identity = nil
|
|
55
64
|
|
|
56
65
|
def record_heartbeat(key, ttl: Smith.config.persistence_ttl)
|
|
57
66
|
Retry.with_retries(operation: :record_heartbeat, transient: self.class.transient_errors) do
|
|
@@ -79,9 +88,9 @@ module Smith
|
|
|
79
88
|
# Smith::PersistenceVersionConflict on a stale expected_version
|
|
80
89
|
# OR on EXEC failure (WATCH detected concurrent write).
|
|
81
90
|
def store_versioned(key, payload, expected_version:, ttl: Smith.config.persistence_ttl)
|
|
82
|
-
|
|
91
|
+
without_reconnection do |redis|
|
|
83
92
|
RedisVersionedWrite.new(
|
|
84
|
-
client:
|
|
93
|
+
client: redis,
|
|
85
94
|
key: key,
|
|
86
95
|
storage_key: namespaced(key),
|
|
87
96
|
payload: payload,
|
|
@@ -89,19 +98,27 @@ module Smith
|
|
|
89
98
|
ttl: ttl
|
|
90
99
|
).call
|
|
91
100
|
end
|
|
101
|
+
rescue *self.class.transient_errors => e
|
|
102
|
+
raise Smith::PersistenceIOError.new(operation: :store_versioned, cause: e)
|
|
92
103
|
end
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
def replace_exact(key, payload, expected_payload:, ttl: Smith.config.persistence_ttl)
|
|
106
|
+
without_reconnection do |redis|
|
|
107
|
+
RedisExactWrite.new(
|
|
108
|
+
client: redis,
|
|
109
|
+
key: key,
|
|
110
|
+
storage_key: namespaced(key),
|
|
111
|
+
payload: payload,
|
|
112
|
+
expected_payload: expected_payload,
|
|
113
|
+
ttl: ttl
|
|
114
|
+
).call
|
|
102
115
|
end
|
|
116
|
+
rescue *self.class.transient_errors => e
|
|
117
|
+
raise Smith::PersistenceIOError.new(operation: :replace_exact, cause: e)
|
|
103
118
|
end
|
|
104
119
|
|
|
120
|
+
private
|
|
121
|
+
|
|
105
122
|
def namespaced(key)
|
|
106
123
|
[@namespace, key].compact.join(":")
|
|
107
124
|
end
|
|
@@ -16,27 +16,39 @@ module Smith
|
|
|
16
16
|
|
|
17
17
|
def with_retries(operation:, transient:, policy: Smith.config.persistence_retry_policy,
|
|
18
18
|
logger: Smith.config.logger)
|
|
19
|
-
|
|
20
|
-
base = policy.fetch(:base_delay, 0.1)
|
|
21
|
-
max_delay = policy.fetch(:max_delay, 1.0)
|
|
19
|
+
schedule = schedule_for(policy)
|
|
22
20
|
last_error = nil
|
|
23
21
|
|
|
24
|
-
attempts.times do |i|
|
|
22
|
+
schedule.attempts.times do |i|
|
|
25
23
|
return yield
|
|
26
24
|
rescue *transient => e
|
|
27
25
|
last_error = e
|
|
28
|
-
break if i == attempts - 1
|
|
26
|
+
break if i == schedule.attempts - 1
|
|
29
27
|
|
|
30
|
-
delay =
|
|
31
|
-
logger
|
|
32
|
-
"Smith::PersistenceAdapters::Retry #{operation} attempt #{i + 1}/#{attempts} failed: " \
|
|
33
|
-
"#{e.class}: #{e.message}; sleeping #{delay}s"
|
|
34
|
-
)
|
|
28
|
+
delay = schedule.delay(i + 1)
|
|
29
|
+
log_retry(logger, operation, e, { attempt: i + 1, attempts: schedule.attempts, delay: })
|
|
35
30
|
sleep(delay)
|
|
36
31
|
end
|
|
37
32
|
|
|
38
33
|
raise Smith::PersistenceIOError.new(operation: operation, cause: last_error)
|
|
39
34
|
end
|
|
35
|
+
|
|
36
|
+
def schedule_for(policy)
|
|
37
|
+
ExponentialBackoff.new(
|
|
38
|
+
attempts: policy.fetch(:attempts, 3),
|
|
39
|
+
base_delay: policy.fetch(:base_delay, 0.1),
|
|
40
|
+
max_delay: policy.fetch(:max_delay, 1.0),
|
|
41
|
+
jitter: 0
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def log_retry(logger, operation, error, retry_context)
|
|
46
|
+
logger&.warn(
|
|
47
|
+
"Smith::PersistenceAdapters::Retry #{operation} " \
|
|
48
|
+
"attempt #{retry_context.fetch(:attempt)}/#{retry_context.fetch(:attempts)} failed: " \
|
|
49
|
+
"#{error.class}: #{error.message}; sleeping #{retry_context.fetch(:delay)}s"
|
|
50
|
+
)
|
|
51
|
+
end
|
|
40
52
|
end
|
|
41
53
|
end
|
|
42
54
|
end
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
require "monitor"
|
|
4
4
|
require_relative "persistence_adapters/active_record_connection_errors"
|
|
5
5
|
require_relative "persistence_adapters/active_record_initial_write"
|
|
6
|
+
require_relative "persistence_adapters/active_record_exact_write"
|
|
7
|
+
require_relative "persistence_adapters/active_record_exact_store"
|
|
6
8
|
require_relative "persistence_adapters/payload_version"
|
|
7
9
|
require_relative "persistence_adapters/version_expectation"
|
|
8
10
|
require_relative "persistence_adapters/cache_store"
|
|
9
11
|
require_relative "persistence_adapters/rails_cache"
|
|
10
12
|
require_relative "persistence_adapters/redis_versioned_write"
|
|
13
|
+
require_relative "persistence_adapters/redis_exact_write"
|
|
11
14
|
require_relative "persistence_adapters/redis_store"
|
|
12
15
|
require_relative "persistence_adapters/active_record_store"
|
|
13
16
|
require_relative "persistence_adapters/memory"
|
|
@@ -28,7 +31,10 @@ module Smith
|
|
|
28
31
|
# check support via `supports?(adapter, capability)` and fall back
|
|
29
32
|
# gracefully (e.g., Workflow#persist! warns once and uses plain
|
|
30
33
|
# `store` when `store_versioned` is missing).
|
|
31
|
-
OPTIONAL_METHODS = %i[
|
|
34
|
+
OPTIONAL_METHODS = %i[
|
|
35
|
+
store_versioned replace_exact persistence_identity record_heartbeat last_heartbeat
|
|
36
|
+
transaction_open? transaction_identity
|
|
37
|
+
].freeze
|
|
32
38
|
|
|
33
39
|
def self.resolve(adapter, **options)
|
|
34
40
|
return nil if adapter.nil?
|
|
@@ -39,22 +45,7 @@ module Smith
|
|
|
39
45
|
return validate!(instance)
|
|
40
46
|
end
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
when :cache_store
|
|
44
|
-
CacheStore.new(**options)
|
|
45
|
-
when :rails_cache, :solid_cache
|
|
46
|
-
RailsCache.new(**options)
|
|
47
|
-
when :redis
|
|
48
|
-
RedisStore.new(**options)
|
|
49
|
-
when :active_record
|
|
50
|
-
ActiveRecordStore.new(**options)
|
|
51
|
-
when :memory
|
|
52
|
-
Memory.new
|
|
53
|
-
else
|
|
54
|
-
raise ArgumentError, "Unknown persistence adapter #{adapter.inspect}"
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
validate!(built_in)
|
|
48
|
+
validate!(build(adapter, options))
|
|
58
49
|
end
|
|
59
50
|
|
|
60
51
|
def self.adapter_like?(adapter)
|
|
@@ -65,9 +56,21 @@ module Smith
|
|
|
65
56
|
return adapter if adapter_like?(adapter)
|
|
66
57
|
|
|
67
58
|
missing = REQUIRED_METHODS.reject { |method_name| adapter.respond_to?(method_name) }
|
|
68
|
-
raise ArgumentError, "Persistence adapter must implement #{missing.join(
|
|
59
|
+
raise ArgumentError, "Persistence adapter must implement #{missing.join(", ")}"
|
|
69
60
|
end
|
|
70
61
|
|
|
62
|
+
def self.build(adapter, options)
|
|
63
|
+
case adapter.to_sym
|
|
64
|
+
when :cache_store then CacheStore.new(**options)
|
|
65
|
+
when :rails_cache, :solid_cache then RailsCache.new(**options)
|
|
66
|
+
when :redis then RedisStore.new(**options)
|
|
67
|
+
when :active_record then ActiveRecordStore.new(**options)
|
|
68
|
+
when :memory then Memory.new(**options)
|
|
69
|
+
else raise ArgumentError, "Unknown persistence adapter #{adapter.inspect}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
private_class_method :build
|
|
73
|
+
|
|
71
74
|
# Capability introspection used by Workflow#persist! to decide
|
|
72
75
|
# whether the adapter supports optimistic locking via store_versioned.
|
|
73
76
|
def self.supports?(adapter, capability)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class PersistencePayloadConflict < Error
|
|
5
|
+
attr_reader :key
|
|
6
|
+
|
|
7
|
+
def initialize(key:)
|
|
8
|
+
@key = key
|
|
9
|
+
super("persisted payload conflict for #{key.inspect}: the exact expected payload is no longer current")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -22,8 +22,8 @@ module Smith
|
|
|
22
22
|
|
|
23
23
|
def commit_tool_call_charges!(ledger, allowance, workflow_active)
|
|
24
24
|
if workflow_active
|
|
25
|
-
ledger.reserve!(:tool_calls, 1)
|
|
26
|
-
ledger.reconcile!(
|
|
25
|
+
reservation = ledger.reserve!(:tool_calls, 1)
|
|
26
|
+
ledger.reconcile!(reservation, 1)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
allowance[:remaining] -= 1 if allowance
|
data/lib/smith/types.rb
CHANGED