smith-agents 0.4.4 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +8 -2
- data/docs/PERSISTENCE.md +180 -1
- data/docs/workflow_claim.md +4 -1
- data/lib/smith/doctor/checks/persistence_capabilities.rb +1 -0
- data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
- data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +91 -51
- data/lib/smith/persistence_adapters/cache_store.rb +3 -1
- data/lib/smith/persistence_adapters/memory.rb +29 -13
- data/lib/smith/persistence_adapters/payload_version.rb +23 -0
- data/lib/smith/persistence_adapters/redis_store.rb +11 -33
- data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
- data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
- data/lib/smith/persistence_adapters.rb +6 -1
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/durability.rb +1 -0
- data/lib/smith/workflow/persistence.rb +12 -2
- data/lib/smith/workflow/split_step_persistence/boundary.rb +115 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +121 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +126 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +20 -0
- data/lib/smith/workflow/split_step_persistence/payloads.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +97 -0
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +91 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +46 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +52 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
- data/lib/smith/workflow/split_step_persistence.rb +43 -0
- data/lib/smith/workflow.rb +13 -1
- data/lib/smith.rb +1 -0
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a4cd092162200b5cb861d9b9cd48069eab2e00beef71abf5fbfb91705c235ee
|
|
4
|
+
data.tar.gz: 483180aec2d6b6d63138324edfb10f564ca2c4ff800603eeb3a43807f4b20f95
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 240e30a347d30d06063e57bece46ef646ce68c31463192a123cffd47858311ee1a92d6f7c664c519347775ff5df861e63ae8103005806cf39e400826b3e04c75
|
|
7
|
+
data.tar.gz: e478845f5665afeea75fb2b905dd4a7c48aa4ff56ca748661a1dc8302f44dacb029fa60117d900acfeca5558a52a9fde5bc8c7552a7a8149808b92b4898d1c82
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,67 @@ 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
|
+
|
|
9
70
|
## [0.4.4] - 2026-07-10
|
|
10
71
|
|
|
11
72
|
Patch release for provider-safe workflow handoffs. Smith keeps accepted agent
|
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.
|
|
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
|
|
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
|
-
|
data/docs/workflow_claim.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
"ActiveRecordStore#store_versioned requires a #{@version_column} column on " \
|
|
61
|
-
"#{model_class.name}. Add via: " \
|
|
62
|
-
"add_column :#{model_class.table_name}, :#{@version_column}, :integer, default: 0"
|
|
63
|
-
end
|
|
48
|
+
ensure_version_column!
|
|
49
|
+
ensure_locking_configuration!
|
|
64
50
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@model_source
|
|
92
|
-
end
|
|
93
|
-
rescue NameError => e
|
|
94
|
-
raise ArgumentError, "ActiveRecord model #{@model_source.inspect} could not be resolved: #{e.message}"
|
|
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
|
|
@@ -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,8 @@ module Smith
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
def transaction_open? = false
|
|
46
|
+
|
|
45
47
|
def backend_name
|
|
46
48
|
backend.class.name || backend.class.to_s
|
|
47
49
|
end
|