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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -0
  3. data/README.md +116 -6
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +502 -1
  7. data/docs/workflow_claim.md +4 -1
  8. data/lib/smith/agent/lifecycle.rb +19 -10
  9. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  10. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  11. data/lib/smith/agent/registry.rb +8 -0
  12. data/lib/smith/agent.rb +24 -2
  13. data/lib/smith/budget/amount_contract.rb +67 -0
  14. data/lib/smith/budget/amount_representation.rb +43 -0
  15. data/lib/smith/budget/decimal_context.rb +18 -0
  16. data/lib/smith/budget/ledger.rb +110 -18
  17. data/lib/smith/budget/ledger_publication.rb +60 -0
  18. data/lib/smith/budget/ledger_state.rb +21 -0
  19. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  20. data/lib/smith/budget/public_state_validator.rb +25 -0
  21. data/lib/smith/budget/reservation.rb +20 -0
  22. data/lib/smith/budget/reservation_contract.rb +34 -0
  23. data/lib/smith/context/observation_masking.rb +3 -1
  24. data/lib/smith/context/state_injection.rb +1 -1
  25. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
  26. data/lib/smith/error.rb +5 -0
  27. data/lib/smith/errors.rb +6 -0
  28. data/lib/smith/exponential_backoff.rb +128 -0
  29. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  31. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  32. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  33. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  34. data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
  35. data/lib/smith/persistence_adapters/cache_store.rb +4 -1
  36. data/lib/smith/persistence_adapters/memory.rb +45 -14
  37. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  38. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  39. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  40. data/lib/smith/persistence_adapters/redis_store.rb +38 -43
  41. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  42. data/lib/smith/persistence_adapters/retry.rb +22 -10
  43. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  44. data/lib/smith/persistence_adapters.rb +26 -18
  45. data/lib/smith/persistence_payload_conflict.rb +12 -0
  46. data/lib/smith/tool/budget_enforcement.rb +2 -2
  47. data/lib/smith/types.rb +1 -0
  48. data/lib/smith/version.rb +2 -1
  49. data/lib/smith/workflow/branch_env.rb +1 -1
  50. data/lib/smith/workflow/budget_integration.rb +21 -11
  51. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  52. data/lib/smith/workflow/definition_identity.rb +25 -0
  53. data/lib/smith/workflow/deterministic_step.rb +2 -2
  54. data/lib/smith/workflow/dsl.rb +73 -5
  55. data/lib/smith/workflow/durability.rb +2 -1
  56. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  57. data/lib/smith/workflow/execution.rb +16 -18
  58. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  59. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  60. data/lib/smith/workflow/fanout_execution.rb +1 -1
  61. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  62. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  63. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  64. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  65. data/lib/smith/workflow/graph/metrics.rb +1 -3
  66. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  67. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  68. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  69. data/lib/smith/workflow/graph/reachability.rb +19 -31
  70. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  71. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  72. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  73. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  74. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  75. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  76. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  77. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  78. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  79. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  80. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  81. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  82. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  83. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  84. data/lib/smith/workflow/graph/validator.rb +1 -1
  85. data/lib/smith/workflow/graph.rb +62 -4
  86. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  87. data/lib/smith/workflow/identifier.rb +24 -0
  88. data/lib/smith/workflow/message_admission.rb +40 -0
  89. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  90. data/lib/smith/workflow/message_batch.rb +60 -0
  91. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  92. data/lib/smith/workflow/nested_execution.rb +10 -0
  93. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  94. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  95. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  96. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  97. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  98. data/lib/smith/workflow/parallel.rb +19 -22
  99. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  100. data/lib/smith/workflow/parallel_execution.rb +7 -3
  101. data/lib/smith/workflow/persistence.rb +49 -10
  102. data/lib/smith/workflow/prepared_step.rb +122 -0
  103. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  104. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  105. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  106. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  107. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  108. data/lib/smith/workflow/retry_execution.rb +20 -11
  109. data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
  110. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  111. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  112. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
  113. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  114. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  115. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  116. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  117. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  118. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  119. data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
  120. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  121. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  122. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  123. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  124. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  125. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  126. data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
  127. data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
  128. data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
  129. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
  130. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  131. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
  132. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  133. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  134. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  135. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  136. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  137. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  138. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
  139. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  140. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  141. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  142. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  143. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  144. data/lib/smith/workflow/split_step_persistence.rb +79 -0
  145. data/lib/smith/workflow/step_completion.rb +31 -0
  146. data/lib/smith/workflow/string_snapshot.rb +25 -0
  147. data/lib/smith/workflow/transition.rb +122 -133
  148. data/lib/smith/workflow/transition_actionability.rb +22 -0
  149. data/lib/smith/workflow.rb +57 -19
  150. data/lib/smith.rb +42 -2
  151. data/sig/smith.rbs +1 -0
  152. 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
- TRANSIENT_ERROR_NAMES = %w[
10
- ActiveRecord::ConnectionNotEstablished
11
- ActiveRecord::StatementInvalid
12
- ActiveRecord::TransactionIsolationConflict
13
- ].freeze
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: self.class.transient_errors) do
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: self.class.transient_errors) do
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: self.class.transient_errors) do
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
- unless model_class.column_names.include?(@version_column.to_s)
59
- raise ArgumentError,
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
- Retry.with_retries(operation: :store_versioned, transient: self.class.transient_errors) do
66
- record = model_class.find_or_initialize_by(@key_column => key)
67
- if record.persisted? && record.public_send(@version_column) != expected_version
68
- raise Smith::PersistenceVersionConflict.new(
69
- key: key, expected: expected_version, actual: record.public_send(@version_column)
70
- )
71
- end
72
- record.public_send(:"#{@payload_column}=", payload)
73
- record.save!
74
- rescue defined?(::ActiveRecord::StaleObjectError) ? ::ActiveRecord::StaleObjectError : StandardError => e
75
- raise unless defined?(::ActiveRecord::StaleObjectError) && e.is_a?(::ActiveRecord::StaleObjectError)
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
- raise Smith::PersistenceVersionConflict.new(
78
- key: key, expected: expected_version, actual: :concurrent
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
- private
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
- @model_class ||= begin
87
- case @model_source
88
- when String
89
- Object.const_get(@model_source)
90
- else
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