smith-agents 0.4.3 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +8 -2
  4. data/docs/PERSISTENCE.md +180 -1
  5. data/docs/workflow_claim.md +4 -1
  6. data/lib/smith/agent/lifecycle.rb +30 -10
  7. data/lib/smith/doctor/checks/persistence_capabilities.rb +1 -0
  8. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  9. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  10. data/lib/smith/persistence_adapters/active_record_store.rb +91 -51
  11. data/lib/smith/persistence_adapters/cache_store.rb +3 -1
  12. data/lib/smith/persistence_adapters/memory.rb +29 -13
  13. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  14. data/lib/smith/persistence_adapters/redis_store.rb +11 -33
  15. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  16. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  17. data/lib/smith/persistence_adapters.rb +6 -1
  18. data/lib/smith/version.rb +1 -1
  19. data/lib/smith/workflow/durability.rb +1 -0
  20. data/lib/smith/workflow/persistence.rb +12 -2
  21. data/lib/smith/workflow/split_step_persistence/boundary.rb +115 -0
  22. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +121 -0
  23. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  24. data/lib/smith/workflow/split_step_persistence/execution.rb +126 -0
  25. data/lib/smith/workflow/split_step_persistence/inheritance.rb +20 -0
  26. data/lib/smith/workflow/split_step_persistence/payloads.rb +50 -0
  27. data/lib/smith/workflow/split_step_persistence/preparation.rb +97 -0
  28. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +91 -0
  29. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +46 -0
  30. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  31. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +52 -0
  32. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  33. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  34. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  35. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  36. data/lib/smith/workflow/split_step_persistence.rb +43 -0
  37. data/lib/smith/workflow.rb +13 -1
  38. data/lib/smith.rb +1 -0
  39. metadata +22 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b8a1ad3fc2cb40ef0287063f81e0bc01a6bca349b1625c4173daef763785202
4
- data.tar.gz: b870358edea9686d15e164bb3d44e00edf8e50da8cb2d68b8a6a666bbc5d0f79
3
+ metadata.gz: 8a4cd092162200b5cb861d9b9cd48069eab2e00beef71abf5fbfb91705c235ee
4
+ data.tar.gz: 483180aec2d6b6d63138324edfb10f564ca2c4ff800603eeb3a43807f4b20f95
5
5
  SHA512:
6
- metadata.gz: bd9dd710fb5008efc7896e950b52621d3c80dab9be902b323efdc76c3d4d12e4b1c066b2219e5e36c56edea5652add7406ecb1d740db79b320035eee9047cee6
7
- data.tar.gz: 4d5e6b412d39269276096b697a399ad476dd1d4bc370792d8acfdd8f49041b567da2120e56a0fc1e090cbf4c351c9849201c8b0030fd5518f6c69495b175f534
6
+ metadata.gz: 240e30a347d30d06063e57bece46ef646ce68c31463192a123cffd47858311ee1a92d6f7c664c519347775ff5df861e63ae8103005806cf39e400826b3e04c75
7
+ data.tar.gz: e478845f5665afeea75fb2b905dd4a7c48aa4ff56ca748661a1dc8302f44dacb029fa60117d900acfeca5558a52a9fde5bc8c7552a7a8149808b92b4898d1c82
data/CHANGELOG.md CHANGED
@@ -6,6 +6,93 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.5] - 2026-07-11
10
+
11
+ Patch release for generic host-coordinated workflow step boundaries and
12
+ fail-closed persistence correctness. Smith now exposes a bounded strict
13
+ split-step protocol while leaving transactions, scheduling, lifecycle records,
14
+ tools, and product policy under host ownership.
15
+
16
+ ### Fixed
17
+
18
+ - Align `ActiveRecordStore#store_versioned` with Smith's persistence contract by
19
+ comparing `expected_version` to the stored payload's `persistence_version`.
20
+ Rails' optimistic-locking column remains an independent row-level CAS token,
21
+ so consecutive workflow persists no longer report a false conflict after the
22
+ initial insert.
23
+ - Fail closed when an Active Record host model does not have optimistic locking
24
+ enabled on the adapter's configured `version_column`. Custom locking columns
25
+ remain host-owned and must be configured on the model explicitly.
26
+ - Use Rails' native create-or-find savepoint for concurrent initial inserts,
27
+ preserving callback rollbacks and distinguishing key collisions from other
28
+ unique-constraint failures.
29
+ - Keep malformed payload-version handling consistent across versioned adapters,
30
+ reject scalar state documents, fail closed when an explicit persisted version
31
+ is invalid, and never replay an uncertain versioned write below a host-owned
32
+ transaction boundary.
33
+ - Resolve string-backed Active Record models on each operation so host framework
34
+ reloads cannot leave the adapter holding a stale class object.
35
+ - Add a generic strict split-step persistence contract for hosts that coordinate
36
+ pre/post transition state without holding a transaction across provider or
37
+ tool execution. Mutable execution state and non-expiring persistence policy
38
+ are pinned for the boundary, subclass entry points remain guarded, and a
39
+ proven transaction rollback can retry from the exact committed preparation.
40
+ Same-transaction atomicity remains adapter- and host-owned.
41
+ - Prevent Memory, Redis, and Active Record versioned adapters from recreating a
42
+ missing key when the caller expects a nonzero logical version. Missing state
43
+ now reports `PersistenceVersionConflict` with `actual: :missing`.
44
+ - Bound split-step transition contract capture to cycle-aware `O(V + E)` traversal with explicit
45
+ node, byte, and depth limits; reject opaque mutable values; freeze supported
46
+ structured configuration; and preserve execution guards when workflow
47
+ subclasses receive later prepends.
48
+ - Keep prepared transition execution on Smith's owned `advance!` path so host
49
+ wrappers cannot run as transition authority inside an active boundary.
50
+ - Make the split-step aggregate own its internal require order so direct loading
51
+ does not depend on `smith.rb` preloading implementation files.
52
+ - Require reconciliation before retrying an ambiguously acknowledged
53
+ checkpoint and retain a single checkpoint witness, keeping retry state in
54
+ constant space.
55
+ - Make Memory expiry atomic with version comparison, isolate mutable payload
56
+ strings at its boundary, and pin Active Record column configuration.
57
+
58
+ ### Verification
59
+
60
+ - Default suite: 1,045 examples, 0 failures.
61
+ - Focused split-step and versioned-adapter suite: 107 examples, 0 failures;
62
+ changed files pass RuboCop and `git diff --check`.
63
+ - Practical gem execution: 30 distinct 20-step workflow classes, 600 Memory
64
+ split steps, 1,000 Memory compare-and-swap writes, and 200 Active Record
65
+ split steps with restore after every committed checkpoint.
66
+ - Smith Runtime host acceptance on Ruby 4.0.1 and Rails 8.1.3: 251 tests,
67
+ 816 assertions, 0 failures; 20 practical signed-package compiles produced
68
+ valid Smith reports and cleaned every generated namespace.
69
+
70
+ ## [0.4.4] - 2026-07-10
71
+
72
+ Patch release for provider-safe workflow handoffs. Smith keeps accepted agent
73
+ outputs in durable session history while adapting only the next provider call
74
+ when a completed workflow stage would otherwise look like an unsupported
75
+ assistant prefill.
76
+
77
+ ### Fixed
78
+
79
+ - Adapt workflow-prepared provider input that ends with an accepted assistant
80
+ result by adding a non-persisted user continuation for the next agent call.
81
+ This preserves Smith session history while avoiding unsupported assistant
82
+ prefilling on provider models that require a user turn before completion.
83
+ Provider preparation now also reads string-keyed roles and content restored
84
+ through JSON host persistence. Explicit assistant-prefill seed messages remain
85
+ unchanged unless they match Smith's recorded accepted workflow output.
86
+
87
+ ### Verification
88
+
89
+ - Default suite: 932 examples, 0 failures.
90
+ - Practical gem-level JSON persistence/restore probe with a 25-branch parallel
91
+ handoff, provider-safe message ordering, non-persisted continuation, and
92
+ explicit assistant-prefill preservation.
93
+ - Smith Runtime host verification on Ruby 4.0.1 and Rails 8.1.3: 139 tests,
94
+ 391 assertions, 0 failures, plus a process-level restored workflow run.
95
+
9
96
  ## [0.4.3] - 2026-07-05
10
97
 
11
98
  ### Documentation
data/README.md CHANGED
@@ -17,7 +17,7 @@ environment before calling the slice complete.
17
17
 
18
18
  ```ruby
19
19
  # Gemfile
20
- gem "smith-agents", "~> 0.4.3", require: "smith"
20
+ gem "smith-agents", "0.4.5", require: "smith"
21
21
  ```
22
22
 
23
23
  ```bash
@@ -211,7 +211,8 @@ Built-in adapters (all support TTL where the backend allows; `Redis`,
211
211
  - `:redis` — Redis client; uses WATCH/MULTI/EXEC for CAS
212
212
  - `:rails_cache`, `:solid_cache` — Rails cache backends
213
213
  - `:cache_store` — any object responding to `write/read/delete`
214
- - `:active_record` — keyed ActiveRecord model with `lock_version` column for CAS
214
+ - `:active_record` — keyed ActiveRecord model with an enabled optimistic-locking
215
+ column for row-level CAS; Smith's logical version remains in the JSON payload
215
216
 
216
217
  See [`docs/PERSISTENCE.md`](docs/PERSISTENCE.md) for schema versioning, seed-drift validation, and the `idempotency_mode :strict` step-in-progress contract.
217
218
 
@@ -297,6 +298,11 @@ and external idempotency guarantees remain host-owned.
297
298
 
298
299
  ## Development
299
300
 
301
+ Smith changes must not use brute-force graph search, validation, scheduling, or
302
+ class-generation algorithms. Document the relevant time and space complexity,
303
+ prefer indexed, linear, logarithmic, or otherwise bounded approaches, and treat
304
+ an avoidable asymptotic regression as a release blocker.
305
+
300
306
  ```bash
301
307
  bundle install
302
308
  bundle exec rspec
data/docs/PERSISTENCE.md CHANGED
@@ -120,6 +120,186 @@ workflow.clear_persisted!("ticket:T-1042")
120
120
 
121
121
  These helpers do not make Smith a job system or durable runtime. They only remove repetitive restore/checkpoint boilerplate around the configured persistence adapter while leaving queueing, projection, and recovery policy with the host app.
122
122
 
123
+ ### Host-Coordinated Step Boundaries
124
+
125
+ Hosts can split one strict transition into explicit prepare, execute, and
126
+ checkpoint phases:
127
+
128
+ ```ruby
129
+ transition_name = nil
130
+
131
+ ApplicationRecord.transaction do
132
+ transition_name = workflow.prepare_persisted_step!(key, adapter: adapter)
133
+ HostEvent.create!(name: "step.started", transition: transition_name)
134
+ end
135
+
136
+ workflow.confirm_prepared_step!
137
+ step = workflow.execute_prepared_step! # provider/tool work; no host transaction
138
+
139
+ ApplicationRecord.transaction do
140
+ workflow.persist!(key, adapter: adapter)
141
+ HostEvent.create!(name: "step.completed", transition: step.fetch(:transition))
142
+ end
143
+
144
+ workflow.complete_persisted_step!
145
+ ```
146
+
147
+ This contract is available only to workflows using `idempotency_mode :strict`,
148
+ a versioned persistence adapter whose `store_versioned` method accepts `ttl:`,
149
+ and non-expiring workflow persistence. Smith explicitly pins `ttl: nil` for
150
+ every boundary write, so a later global configuration change cannot turn an
151
+ accepted checkpoint into expiring state.
152
+ `prepare_persisted_step!` writes the normal `step_in_progress` marker and returns
153
+ the pending transition name without consuming it. Duplicate preparation on the
154
+ same workflow object is rejected. The boundary is pinned to the exact
155
+ transition object, persistence key, and adapter instance selected during
156
+ preparation. The key is copied into an immutable string and the transition
157
+ contract is frozen. When an adapter can report an open transaction,
158
+ `confirm_prepared_step!` must verify the committed preparation before execution;
159
+ it refuses confirmation while that transaction is still open.
160
+ `execute_prepared_step!` re-verifies the exact durable preparation and permits
161
+ one execution attempt. Preparation also takes an O(S) defensive snapshot of
162
+ mutable workflow execution state, where S is the serialized state size. This
163
+ is the necessary ownership boundary: aliases held before preparation cannot
164
+ change what the accepted transition later consumes, and public mutable-state
165
+ readers and `to_state` return defensive snapshots while the boundary is active.
166
+ Subclass execution entry points remain guarded, and subclass TTL helpers cannot
167
+ override the pinned boundary policy. The
168
+ in-memory marker remains armed so any serialization
169
+ before committed completion still fails closed. Other workflow execution and
170
+ checkpoint APIs are rejected until the host checkpoints the accepted state
171
+ through `persist!`. After that transaction commits,
172
+ `complete_persisted_step!` verifies the exact checkpoint through the adapter
173
+ before clearing the marker and releasing the process-local boundary, and
174
+ likewise refuses to run while the adapter reports an open transaction.
175
+ `prepared_persisted_step?` exposes whether the one execution attempt remains
176
+ available without revealing transition internals.
177
+
178
+ Workflow classes are part of the execution contract once preparation begins.
179
+ Hosts must not add or prepend methods to that class until the boundary is
180
+ complete, and custom `inherited` hooks must call `super`. Ruby intentionally
181
+ allows open-class mutation, so Smith treats post-preparation class mutation as
182
+ an unsupported host lifecycle violation rather than attempting to sandbox it.
183
+
184
+ The lifecycle row and Smith checkpoint are atomic only when the persistence
185
+ adapter participates in the same transaction and database connection domain as
186
+ the host record. `ActiveRecordStore` can provide that property when its model
187
+ uses the same database connection; it also reports transaction state so Smith
188
+ cannot confirm an uncommitted boundary. Memory, Redis, cache-backed,
189
+ cross-database, and external adapters do not participate in
190
+ `ApplicationRecord.transaction`; hosts using them must supply their own
191
+ coordination protocol and must not claim the two writes are atomic.
192
+ Custom transactional adapters should implement `transaction_open?` so Smith can
193
+ enforce the same commit-aware confirmation rule.
194
+
195
+ If execution raises or the process dies after external work, the durable marker
196
+ remains set. A strict restore therefore fails closed with
197
+ `StepInProgressOnRestore`; the host must reconcile operation results or classify
198
+ the run as uncertain rather than blindly replaying the transition. The same
199
+ in-memory workflow object cannot retry an attempted transition. If a host
200
+ transaction rolls back after a successful `persist!`,
201
+ `complete_persisted_step!` rejects the rolled-back checkpoint. When the adapter
202
+ still contains the exact committed preparation, Smith restores that known
203
+ version and permits the same in-memory object to retry the unchanged checkpoint.
204
+ Any different or missing payload remains uncertain and non-retryable.
205
+
206
+ Ambiguous persistence acknowledgements also fail closed. If preparation may
207
+ have written before raising, the object cannot execute. If a post-step
208
+ checkpoint may have written before raising, `complete_persisted_step!` can
209
+ verify the exact attempted payload without replaying the write. The host must
210
+ perform that reconciliation before retrying. If the adapter still contains the
211
+ exact preparation, Smith permits one unchanged retry; any other result remains
212
+ non-retryable. Smith retains one checkpoint witness, so retry bookkeeping is
213
+ constant space rather than growing with the number of attempts.
214
+
215
+ Transition contract capture uses cycle-aware `O(V + E)` traversal, with time
216
+ and space bounded to 10,000 visits, 4 MiB of string data, and a maximum depth
217
+ of 128. Strings are represented by SHA-256 digests rather than duplicated.
218
+ Supported mutable data shapes are frozen, `Range` endpoints are traversed, and
219
+ opaque mutable objects fail closed instead of receiving a misleading shallow
220
+ signature. Callable identity is pinned for the boundary; Ruby closure state and
221
+ method bodies remain host lifecycle concerns. Smith detects contract
222
+ replacement but does not claim to sandbox Ruby's open object model. Subclass
223
+ entry points remain guarded even when a host prepends modules after class
224
+ creation, and prepared execution bypasses host `advance!` wrappers so they
225
+ cannot become transition execution authority.
226
+
227
+ ## Active Record Optimistic Locking
228
+
229
+ `ActiveRecordStore` keeps two version domains deliberately separate:
230
+
231
+ - `persistence_version` inside the JSON payload is Smith's logical workflow
232
+ version and is compared with `expected_version`.
233
+ - the host model's Active Record `locking_column` is Rails' row-level
234
+ compare-and-swap token and detects concurrent updates between load and save.
235
+
236
+ Across every versioned adapter, a missing key can be created only with
237
+ `expected_version: 0`. A nonzero expected version against missing state raises
238
+ `PersistenceVersionConflict` with `actual: :missing`, preventing stale workflow
239
+ objects from resurrecting host-deleted state.
240
+
241
+ Legacy object-shaped payloads without `persistence_version` use version zero.
242
+ Valid JSON scalars are not workflow-state documents, and explicit null,
243
+ negative, or non-integer versions fail closed instead of being overwritten.
244
+
245
+ The default host model needs a unique key, a JSON/text payload, and Rails'
246
+ standard optimistic-locking column:
247
+
248
+ ```ruby
249
+ create_table :workflow_states do |t|
250
+ t.string :key, null: false, index: { unique: true }
251
+ t.jsonb :payload, null: false
252
+ t.integer :lock_version, null: false, default: 0
253
+ t.timestamps
254
+ end
255
+
256
+ class WorkflowState < ApplicationRecord
257
+ end
258
+
259
+ Smith.configure do |config|
260
+ config.persistence_adapter = :active_record
261
+ config.persistence_options = { model: WorkflowState }
262
+ end
263
+ ```
264
+
265
+ For a custom locking column, configure both the host model and adapter. Smith
266
+ validates this relationship but never mutates the host model:
267
+
268
+ ```ruby
269
+ add_column :workflow_states, :workflow_revision, :integer,
270
+ null: false,
271
+ default: 0
272
+
273
+ class WorkflowState < ApplicationRecord
274
+ self.locking_column = :workflow_revision
275
+ end
276
+
277
+ Smith.configure do |config|
278
+ config.persistence_adapter = :active_record
279
+ config.persistence_options = {
280
+ model: WorkflowState,
281
+ version_column: :workflow_revision
282
+ }
283
+ end
284
+ ```
285
+
286
+ The unique key must be enforced by the database. Initial creation uses Rails'
287
+ native `create_or_find_by!` savepoint path, so a concurrent key insert does not
288
+ invalidate a PostgreSQL caller transaction. A collision on another unique
289
+ constraint is not misreported as a key-version conflict.
290
+
291
+ Versioned writes are single-attempt. Smith translates known connection failures
292
+ to `PersistenceIOError`, but never replays an uncertain write below the
293
+ host-owned transaction boundary. The host must restore and reconcile persisted
294
+ state before deciding whether to retry. Keep the host model persistence-focused:
295
+ callbacks that raise after commit run after the write is permanent and therefore
296
+ create an inherently uncertain outcome.
297
+
298
+ The adapter participates in the caller's Active Record transaction and never
299
+ commits it. Hosts remain responsible for outer transaction boundaries, queue
300
+ claims, fencing, reconciliation, and recovery policy. See the
301
+ [official Rails optimistic-locking documentation](https://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html).
302
+
123
303
  ## Artifacts
124
304
 
125
305
  Use artifacts when outputs are too large to keep inline.
@@ -166,4 +346,3 @@ Why this matters:
166
346
  - large payloads can move out of the inline workflow result
167
347
  - refs are execution-scoped
168
348
  - nested workflows inherit artifact scope correctly
169
-
@@ -55,4 +55,7 @@ If the model has AASM and you want to skip events, call `.cas` explicitly; if yo
55
55
 
56
56
  ## Testing
57
57
 
58
- Specs that exercise the AR strategies are tagged `:ar` and excluded from the default suite. The full suite under `SMITH_AR_SPECS=1` boots an in-memory SQLite database and the `ClaimableRecord` fixture model. Default `bundle exec rspec` runs 816 examples (Claim load-hygiene only); `SMITH_AR_SPECS=1 bundle exec rspec` runs 831 (adds 15 AR-tagged Claim specs).
58
+ The default spec suite boots an in-memory SQLite database and runs the tagged
59
+ Active Record strategy examples against the `ClaimableRecord` fixture model.
60
+ Package smoke tests separately verify that requiring Smith does not make Active
61
+ Record a runtime dependency.
@@ -3,6 +3,10 @@
3
3
  module Smith
4
4
  class Agent
5
5
  module Lifecycle
6
+ WORKFLOW_CONTINUATION_MESSAGE =
7
+ "Use the preceding assistant result as input and perform your assigned workflow step."
8
+ private_constant :WORKFLOW_CONTINUATION_MESSAGE
9
+
6
10
  TRANSIENT_ERRORS = [
7
11
  RubyLLM::ServerError, RubyLLM::ServiceUnavailableError,
8
12
  RubyLLM::OverloadedError, RubyLLM::RateLimitError
@@ -111,6 +115,7 @@ module Smith
111
115
  def add_prepared_input(chat, prepared_input)
112
116
  return unless prepared_input
113
117
 
118
+ prepared_input = provider_safe_prepared_input(prepared_input)
114
119
  system_messages, other_messages = prepared_input.partition do |message|
115
120
  message_role(message) == :system
116
121
  end
@@ -119,6 +124,22 @@ module Smith
119
124
  other_messages.each { |message| chat.add_message(message) }
120
125
  end
121
126
 
127
+ def provider_safe_prepared_input(prepared_input)
128
+ messages = prepared_input.to_a
129
+ return messages unless workflow_handoff?(messages)
130
+
131
+ messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
132
+ end
133
+
134
+ def workflow_handoff?(messages)
135
+ message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
136
+ return false unless message
137
+ return false unless message_role(message) == :assistant
138
+ return false unless defined?(@last_output) && !@last_output.nil?
139
+
140
+ message_content(message) == @last_output
141
+ end
142
+
122
143
  def merge_system_messages!(chat, prepared_system_messages)
123
144
  return prepared_system_messages.each { |message| chat.add_message(message) } unless chat.respond_to?(:messages)
124
145
 
@@ -143,19 +164,18 @@ module Smith
143
164
  end
144
165
 
145
166
  def message_role(message)
146
- if message.respond_to?(:role)
147
- message.role&.to_sym
148
- else
149
- message[:role]&.to_sym
150
- end
167
+ message_attribute(message, :role)&.to_sym
151
168
  end
152
169
 
153
170
  def message_content(message)
154
- if message.respond_to?(:content)
155
- message.content
156
- else
157
- message[:content]
158
- end
171
+ message_attribute(message, :content)
172
+ end
173
+
174
+ def message_attribute(message, name)
175
+ return message.public_send(name) if message.respond_to?(name)
176
+ return message[name] if message.respond_to?(:key?) && message.key?(name)
177
+
178
+ message[name.to_s]
159
179
  end
160
180
 
161
181
  def fallback_eligible?(error)
@@ -58,6 +58,7 @@ module Smith
58
58
  message: "#{adapter.class.name} missing optional capabilities: #{missing.join(", ")}",
59
59
  detail: "Smith will fall back where possible: non-versioned writes when store_versioned " \
60
60
  "is missing, and payload updated_at parsing when heartbeat methods are missing. " \
61
+ "Without transaction_open?, commit-aware split-step confirmation is unavailable. " \
61
62
  "Use RedisStore or Memory for full versioning and heartbeat coverage; " \
62
63
  "ActiveRecordStore currently covers optimistic locking when lock_version is present."
63
64
  )
@@ -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,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
@@ -6,32 +6,18 @@ module Smith
6
6
  # AR transient errors resolved via class-name guard so Smith
7
7
  # doesn't require activerecord at load time. Hosts that use this
8
8
  # 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
9
  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
10
+ @model_source = model.is_a?(String) ? model.dup.freeze : model
11
+ @key_column = normalize_column(key_column)
12
+ @payload_column = normalize_column(payload_column)
13
+ @version_column = normalize_column(version_column)
28
14
  end
29
15
 
30
16
  def store(key, payload, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
31
17
  # TTL is deferred for ActiveRecordStore — would require an
32
18
  # `expires_at` column + a periodic sweeper job. Ignored here;
33
19
  # documented as a known limitation.
34
- Retry.with_retries(operation: :store, transient: self.class.transient_errors) do
20
+ Retry.with_retries(operation: :store, transient: ActiveRecordConnectionErrors.classes) do
35
21
  record = model_class.find_or_initialize_by(@key_column => key)
36
22
  record.public_send(:"#{@payload_column}=", payload)
37
23
  record.save!
@@ -39,60 +25,114 @@ module Smith
39
25
  end
40
26
 
41
27
  def fetch(key)
42
- Retry.with_retries(operation: :fetch, transient: self.class.transient_errors) do
28
+ Retry.with_retries(operation: :fetch, transient: ActiveRecordConnectionErrors.classes) do
43
29
  model_class.find_by(@key_column => key)&.public_send(@payload_column)
44
30
  end
45
31
  end
46
32
 
47
33
  def delete(key)
48
- Retry.with_retries(operation: :delete, transient: self.class.transient_errors) do
34
+ Retry.with_retries(operation: :delete, transient: ActiveRecordConnectionErrors.classes) do
49
35
  model_class.where(@key_column => key).delete_all
50
36
  end
51
37
  end
52
38
 
39
+ def transaction_open?
40
+ model_class.connection.transaction_open?
41
+ end
42
+
53
43
  # Optimistic locking via Rails' built-in optimistic locking on the
54
44
  # `lock_version` column. Requires the AR model to have a
55
45
  # `lock_version` (or configured) integer column with default 0.
56
46
  # If absent, raises ArgumentError directing the host to migrate.
57
47
  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
48
+ ensure_version_column!
49
+ ensure_locking_configuration!
64
50
 
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)
51
+ write_versioned(key, payload, expected_version)
52
+ rescue *ActiveRecordConnectionErrors.classes => e
53
+ raise Smith::PersistenceIOError.new(operation: :store_versioned, cause: e)
54
+ end
55
+
56
+ private
76
57
 
77
- raise Smith::PersistenceVersionConflict.new(
78
- key: key, expected: expected_version, actual: :concurrent
79
- )
58
+ def normalize_column(column)
59
+ column.is_a?(String) ? column.dup.freeze : column
60
+ end
61
+
62
+ def ensure_version_column!
63
+ return if model_class.column_names.include?(@version_column.to_s)
64
+
65
+ raise ArgumentError,
66
+ "ActiveRecordStore#store_versioned requires a #{@version_column} column on " \
67
+ "#{model_class.name}. Add via: " \
68
+ "add_column :#{model_class.table_name}, :#{@version_column}, :integer, default: 0"
69
+ end
70
+
71
+ def ensure_locking_configuration!
72
+ locking_column = model_class.locking_column if model_class.respond_to?(:locking_column)
73
+ locking_enabled = model_class.locking_enabled? if model_class.respond_to?(:locking_enabled?)
74
+ return if locking_enabled && locking_column.to_s == @version_column.to_s
75
+
76
+ raise ArgumentError,
77
+ "ActiveRecordStore#store_versioned requires #{model_class.name}.locking_column " \
78
+ "to be #{@version_column.inspect} with optimistic locking enabled"
79
+ end
80
+
81
+ def write_versioned(key, payload, expected_version)
82
+ record = model_class.find_by(@key_column => key)
83
+ unless record
84
+ VersionExpectation.validate_missing!(key, expected_version)
85
+ return create_versioned_record(key, payload, expected_version)
80
86
  end
87
+
88
+ validate_payload_version!(record, key, expected_version)
89
+ record.public_send(:"#{@payload_column}=", payload)
90
+ record.save!
91
+ rescue ::ActiveRecord::StaleObjectError => e
92
+ raise unless e.record.equal?(record)
93
+
94
+ raise_concurrent_conflict(key, expected_version)
81
95
  end
82
96
 
83
- private
97
+ def create_versioned_record(key, payload, expected_version)
98
+ created = ActiveRecordInitialWrite.call(
99
+ model: model_class,
100
+ key_column: @key_column,
101
+ payload_column: @payload_column,
102
+ key: key,
103
+ payload: payload
104
+ )
105
+ return true if created
106
+
107
+ raise_concurrent_conflict(key, expected_version)
108
+ end
109
+
110
+ def validate_payload_version!(record, key, expected_version)
111
+ current_version = payload_version(record)
112
+ return if current_version == expected_version
113
+
114
+ raise Smith::PersistenceVersionConflict.new(
115
+ key: key, expected: expected_version, actual: current_version
116
+ )
117
+ end
118
+
119
+ def payload_version(record) = PayloadVersion.call(record.public_send(@payload_column))
120
+
121
+ def raise_concurrent_conflict(key, expected_version)
122
+ raise Smith::PersistenceVersionConflict.new(
123
+ key: key, expected: expected_version, actual: :concurrent
124
+ )
125
+ end
84
126
 
85
127
  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}"
128
+ case @model_source
129
+ when String
130
+ Object.const_get(@model_source)
131
+ else
132
+ @model_source
95
133
  end
134
+ rescue NameError => e
135
+ raise ArgumentError, "ActiveRecord model #{@model_source.inspect} could not be resolved: #{e.message}"
96
136
  end
97
137
  end
98
138
  end