solid_objects 0.15.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +151 -0
- data/README.md +1 -1
- data/app/models/solid_objects/administration_event.rb +7 -0
- data/app/models/solid_objects/redrive.rb +7 -0
- data/benchmark/support.rb +2 -8
- data/db/migrate/20260922000000_add_solid_objects_administration_events.rb +26 -0
- data/db/migrate/20260922000001_add_solid_objects_redrives.rb +32 -0
- data/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb +10 -0
- data/docs/adr/0011-wake-up-strategy.md +11 -1
- data/docs/architecture.md +41 -5
- data/docs/dashboard.md +10 -0
- data/docs/fit.md +6 -0
- data/docs/operations.md +153 -7
- data/docs/realtime.md +32 -26
- data/docs/roadmap.md +60 -32
- data/examples/at_least_once/boot.rb +2 -8
- data/lib/solid_objects/activation.rb +13 -6
- data/lib/solid_objects/actor.rb +134 -8
- data/lib/solid_objects/actor_snapshot.rb +8 -5
- data/lib/solid_objects/administration_audit.rb +30 -0
- data/lib/solid_objects/client.rb +81 -0
- data/lib/solid_objects/configuration.rb +47 -2
- data/lib/solid_objects/context.rb +10 -4
- data/lib/solid_objects/dead_letter_manager.rb +42 -6
- data/lib/solid_objects/dead_letter_scope.rb +143 -0
- data/lib/solid_objects/dead_row.rb +15 -0
- data/lib/solid_objects/doctor.rb +24 -5
- data/lib/solid_objects/errors.rb +11 -0
- data/lib/solid_objects/executor.rb +41 -3
- data/lib/solid_objects/message_reference.rb +29 -9
- data/lib/solid_objects/outcome.rb +31 -0
- data/lib/solid_objects/process_registry.rb +3 -1
- data/lib/solid_objects/redrive_manager.rb +130 -0
- data/lib/solid_objects/redrive_runner.rb +60 -0
- data/lib/solid_objects/redrive_task.rb +14 -0
- data/lib/solid_objects/reference.rb +5 -0
- data/lib/solid_objects/schema_bootstrap.rb +27 -0
- data/lib/solid_objects/supervisor.rb +43 -0
- data/lib/solid_objects/test_helper.rb +2 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up.rb +12 -0
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +12 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +12 -0
- data/lib/solid_objects/wake_up_adapters.rb +206 -11
- data/lib/solid_objects/wake_up_capability.rb +15 -0
- data/lib/solid_objects.rb +46 -3
- data/sig/generated/lib/solid_objects/activation.rbs +7 -0
- data/sig/generated/lib/solid_objects/actor.rbs +93 -2
- data/sig/generated/lib/solid_objects/administration_audit.rbs +11 -0
- data/sig/generated/lib/solid_objects/client.rbs +12 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +54 -28
- data/sig/generated/lib/solid_objects/context.rbs +11 -6
- data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +9 -0
- data/sig/generated/lib/solid_objects/dead_letter_scope.rbs +58 -0
- data/sig/generated/lib/solid_objects/dead_row.rbs +30 -0
- data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
- data/sig/generated/lib/solid_objects/errors.rbs +8 -0
- data/sig/generated/lib/solid_objects/executor.rbs +9 -0
- data/sig/generated/lib/solid_objects/message_reference.rbs +8 -0
- data/sig/generated/lib/solid_objects/outcome.rbs +52 -0
- data/sig/generated/lib/solid_objects/redrive_manager.rbs +43 -0
- data/sig/generated/lib/solid_objects/redrive_runner.rbs +22 -0
- data/sig/generated/lib/solid_objects/redrive_task.rbs +33 -0
- data/sig/generated/lib/solid_objects/reference.rbs +5 -2
- data/sig/generated/lib/solid_objects/schema_bootstrap.rbs +11 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +15 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +5 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +75 -9
- data/sig/generated/lib/solid_objects/wake_up_capability.rbs +28 -0
- data/sig/generated/lib/solid_objects.rbs +14 -2
- data/sig/generated/models/solid_objects/administration_event.rbs +6 -0
- data/sig/generated/models/solid_objects/redrive.rbs +6 -0
- data/sig/public/reminder_payload.rbs +3 -0
- metadata +28 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d267d861c25f682b3e45208131b9c115b49710ae43fb499223c6d63f48519a0
|
|
4
|
+
data.tar.gz: 1acc3bb415c57e9072f231fc5e37ed37ce8bab4a9ba5fc894a7dbe5453839d8e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f3d71f114877579419a680b8ff46b56036060d8575c7c6732c4c9d530ed3020fbb58eb61ac3e348810d44ecf09e8553b4f07390bd1c22460b5604005578e06f
|
|
7
|
+
data.tar.gz: 07e811b54283aa3f3638ae90b8fbc449f5e151dbcae69e01487a8231a1b8cc5556bd1cc035b67aa347f080329a64f9c0394965d468860dd989f0ba40afdc7552
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,156 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0 - 2026-09-23
|
|
4
|
+
|
|
5
|
+
- Find a message whose reference a caller lost.
|
|
6
|
+
`SolidObjects.client.find_by(request_id:)` answers a request id, which is
|
|
7
|
+
unique across the table, and `reference.find_by(idempotency_key:)` answers a
|
|
8
|
+
key, which is unique per instance, so the receiver supplies the scope the key
|
|
9
|
+
needs. Naming neither key, naming both, or naming an idempotency key without a
|
|
10
|
+
reference raises `ArgumentError`.
|
|
11
|
+
- Authorize every lookup with the hook the original call ran, against the stored
|
|
12
|
+
operation and arguments, because a request id is not a capability. An absent
|
|
13
|
+
row, an actor this process no longer registers, and a caller the policy
|
|
14
|
+
refuses all return `nil`, so a lookup cannot be used to ask whether a request
|
|
15
|
+
id exists.
|
|
16
|
+
- Add `MessageReference#outcome`, which reports the status, the result, the
|
|
17
|
+
persisted error, the rejection, and the attempt count, so a terminal failure
|
|
18
|
+
answers as well as a success. A result is stored for `sync` delivery only, so
|
|
19
|
+
an asynchronous message reports its status and error and no result.
|
|
20
|
+
- Tell a pruned message from one that never existed. An actor remembers the
|
|
21
|
+
idempotency keys of its own finished turns, the way an Orleans grain keeps
|
|
22
|
+
its deduplication history in grain state, so the memory needs no second
|
|
23
|
+
store and no second write. `reference.find_by(idempotency_key:)` raises
|
|
24
|
+
`SolidObjects::MessagePruned` for a key the actor remembers and whose message
|
|
25
|
+
retention removed, and still answers `nil` for a key no caller ever sent.
|
|
26
|
+
An actor remembers the operation beside each key, so the pruned answer runs
|
|
27
|
+
the same hook against the same operation a lookup of the surviving row would,
|
|
28
|
+
and a caller the policy refuses reads `nil` for both. Gating it on `snapshot`
|
|
29
|
+
would have told a caller who may read state, but not the operation, that the
|
|
30
|
+
operation had run.
|
|
31
|
+
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for
|
|
32
|
+
each actor. A lookup by request id cannot make the distinction, because the
|
|
33
|
+
runtime, not the caller, generates a request id and no actor remembers one.
|
|
34
|
+
`retained_idempotency_keys_bytes` bounds the serialized memory as well,
|
|
35
|
+
because an idempotency key has no length limit on every adapter and the memory
|
|
36
|
+
outlives the message row. An actor drops its oldest keys until the list fits,
|
|
37
|
+
so a key long enough to fill the limit by itself is never remembered.
|
|
38
|
+
- Add `db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb`,
|
|
39
|
+
which adds `instances.completed_idempotency_keys` as `jsonb` on PostgreSQL and
|
|
40
|
+
`json` elsewhere. An application installs it with
|
|
41
|
+
`bin/rails solid_objects:install:migrations` and runs it before it upgrades a
|
|
42
|
+
worker, because the executor writes the column on every finished turn. The
|
|
43
|
+
doctor now reports the column as missing when it is not installed.
|
|
44
|
+
- Apply migrations through `SolidObjects::SchemaBootstrap`, which reads
|
|
45
|
+
`db/migrate`. Seven scripts each carried a hand-copied migration list, and
|
|
46
|
+
three of them applied an incomplete schema. A test fails if any script names a
|
|
47
|
+
migration class again.
|
|
48
|
+
- Report a half-applied migration in `solid_objects doctor`. The column list
|
|
49
|
+
omitted `instances.state_revision`, `messages.operation`,
|
|
50
|
+
`effects.success_operation`, `effects.failure_operation`, and
|
|
51
|
+
`dead_letters.operation`, so an application that skipped a migration read as
|
|
52
|
+
healthy and found out from a worker crash. A test fails when the list does not
|
|
53
|
+
name a column that a migration after the first adds.
|
|
54
|
+
|
|
55
|
+
- List a dead effect or broadcast as a `SolidObjects::DeadRow` rather than as
|
|
56
|
+
an Active Record row. `all` returned rows whose `id` was the primary key while
|
|
57
|
+
`retry` reads `effect_id` or `broadcast_id`, so the obvious
|
|
58
|
+
`scope.retry(scope.all.first.id)` raised `ActiveRecord::RecordNotFound`.
|
|
59
|
+
`DeadRow#id` is now the value `retry` accepts, which is what the TypeScript
|
|
60
|
+
runtime has always returned. `dead` still answers the relation for a caller
|
|
61
|
+
that wants to scope it further.
|
|
62
|
+
- Raise a load error rather than report an unreachable database. Wake-up
|
|
63
|
+
selection rescued every exception, so a `NameError` from an unloaded model
|
|
64
|
+
read as "the database could not be reached" and downgraded the process to
|
|
65
|
+
in-process signalling. It now rescues database, system call, and IO errors
|
|
66
|
+
only.
|
|
67
|
+
- Note that `json` 3.0.2 breaks `ActiveSupport::JSON.decode`, and therefore
|
|
68
|
+
every JSON column, in [docs/operations.md](docs/operations.md).
|
|
69
|
+
- Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its
|
|
70
|
+
message meaning and answers `effects` and `broadcasts`, so the kind rides on
|
|
71
|
+
the receiver. `retry` returns a dead row to pending with a zero attempt count
|
|
72
|
+
and no claim, reuses the stable id so a deduplicating handler sees the same
|
|
73
|
+
key, and acts only on a dead row, so a second press cannot double-enqueue. A
|
|
74
|
+
dead transmit effect replays rather than stay lost.
|
|
75
|
+
- Redrive a whole scope. `redrive` opens a durable task, returns at once, and is
|
|
76
|
+
idempotent over its scope and filters, which a dashboard button needs. A
|
|
77
|
+
unique index on the active scope enforces that in the database, so two
|
|
78
|
+
processes that start the same redrive share one task. The supervisor advances
|
|
79
|
+
one bounded batch per pass, so a redrive never holds a transaction longer than
|
|
80
|
+
one batch. `SolidObjects.redrives` reads tasks back, and `task.cancel` stops
|
|
81
|
+
one and leaves the rows it already moved. A redrive moves what was dead when
|
|
82
|
+
it started, so a still-broken handler cannot make it run forever.
|
|
83
|
+
- Record who pressed what. Every retry and every redrive transition writes one
|
|
84
|
+
row to `solid_objects_administration_events`. The identity comes from the
|
|
85
|
+
authorization context through a new `administration_identity` hook.
|
|
86
|
+
- Add `redrive_batch_size`, which defaults to 100, and `redrive_batch_pause`,
|
|
87
|
+
which defaults to 0.05 seconds.
|
|
88
|
+
- Add two tables, `solid_objects_administration_events` and
|
|
89
|
+
`solid_objects_redrives`. Run `bin/rails solid_objects:install:migrations` and
|
|
90
|
+
migrate.
|
|
91
|
+
|
|
92
|
+
- Select a wake-up adapter automatically. `config.wake_up_adapter` now takes a
|
|
93
|
+
name or an adapter, as `config.cache_store` and
|
|
94
|
+
`config.active_job.queue_adapter` do, and defaults to `:automatic`. Selection
|
|
95
|
+
prefers a configured Redis URL, then PostgreSQL notifications, then polling.
|
|
96
|
+
`:in_process` opts out, and an unknown name raises rather than quietly
|
|
97
|
+
polling.
|
|
98
|
+
- PostgreSQL deployments that configure nothing now use notifications. They gain
|
|
99
|
+
cross-process wake-up, a connection per waiting thread outside the pool, and
|
|
100
|
+
one `NOTIFY` per enqueue after the commit. Set
|
|
101
|
+
`config.wake_up_adapter = :in_process` to keep polling.
|
|
102
|
+
- Prove the PostgreSQL notification path before selecting it, because `LISTEN`
|
|
103
|
+
does not survive a transaction pooler such as PgBouncer. Selection listens on
|
|
104
|
+
a probe channel, sends one `NOTIFY` from a second connection, and waits up to
|
|
105
|
+
two seconds for it to arrive. A probe that does not deliver falls back to
|
|
106
|
+
polling and warns once.
|
|
107
|
+
- Select the wake-up adapter once per process. `SolidObjects.wake_up` memoised
|
|
108
|
+
without a lock, so threads that raced for the first use each ran a full
|
|
109
|
+
selection.
|
|
110
|
+
- Keep the capability that a configured adapter reports about itself. A
|
|
111
|
+
configured `SolidObjects::WakeUp` now reports `:in_process` and warns, rather
|
|
112
|
+
than claim that it crosses processes.
|
|
113
|
+
- Poll rather than pretend when a requested adapter cannot be built.
|
|
114
|
+
`wake_up_adapter = :postgresql` on a database with no notification channel,
|
|
115
|
+
`:redis` without `SOLID_OBJECTS_REDIS_URL`, and a Redis URL without the redis
|
|
116
|
+
gem each log `solid_objects.wake_up.unavailable` once and record the reason in
|
|
117
|
+
the capability, so the doctor warns rather than claim a cross-process wake-up
|
|
118
|
+
that cannot happen.
|
|
119
|
+
- Validate `wake_up_adapter` in `configure`. An unknown name raised at the first
|
|
120
|
+
wake-up, which is after a commit, rather than at boot.
|
|
121
|
+
- Report the resolved choice. `SolidObjects.wake_up.capability` names the
|
|
122
|
+
adapter, whether it crosses processes, its measured floor, and why it was
|
|
123
|
+
chosen. The doctor reports it, and the polling-only warning now fires on what
|
|
124
|
+
was installed rather than on whether a setting was set.
|
|
125
|
+
- Read the durable row rather than the query cache in `MessageReference#status`,
|
|
126
|
+
`MessageReference#result`, and an actor snapshot. A caller that polls holds one
|
|
127
|
+
query cache for the whole poll, and the worker that finishes the message is
|
|
128
|
+
another process, so its write cannot clear that cache. A poll inside a request,
|
|
129
|
+
a job, or `rails runner` reported the first answer forever. The synchronous
|
|
130
|
+
wait already read uncached.
|
|
131
|
+
- Add reminder cancellation. `unschedule` removes one reminder by operation and
|
|
132
|
+
optional key, or by the handle `schedule` now returns. `unschedule_all`
|
|
133
|
+
removes every key of one operation. Both stage an intent, so a cancel commits
|
|
134
|
+
with the state change that decided it, and a turn that raises cancels nothing.
|
|
135
|
+
- Add reminder reading. `reminder` returns a `ReminderStatus` or `nil`, and
|
|
136
|
+
`reminders` lists every key of one operation. A read applies the intents
|
|
137
|
+
staged in the current turn, so it agrees with what the commit will write. An
|
|
138
|
+
actor reads its own schedule from every path, including activation hooks and
|
|
139
|
+
observables, because it carries its instance rather than reading an ambient
|
|
140
|
+
context that only message dispatch establishes.
|
|
141
|
+
- Leave a one-shot reminder that already fired out of `reminder` and
|
|
142
|
+
`reminders`. Its row stays as `completed`, so a next-run lookup reported an
|
|
143
|
+
old time rather than nothing, and an existence check refused to re-arm an
|
|
144
|
+
alarm that could never fire again.
|
|
145
|
+
- Refuse an unknown operation in `reminder`, `reminders`, `unschedule`, and
|
|
146
|
+
`unschedule_all`. `schedule` already raised `UnknownMessage` for one, so a
|
|
147
|
+
typo cancelled nothing quietly and left a recurring reminder running.
|
|
148
|
+
- A cancel cannot recall an occurrence the scheduler already turned into a
|
|
149
|
+
message. It does pre-empt one the scheduler claimed but has not yet enqueued.
|
|
150
|
+
- `schedule` now returns a reminder handle instead of `nil`. An operation that
|
|
151
|
+
ends with `schedule` and relies on an implicit `nil` result should return
|
|
152
|
+
`nil` explicitly, as `emit` required in 0.15.0.
|
|
153
|
+
|
|
3
154
|
## 0.15.2 - 2026-09-21
|
|
4
155
|
|
|
5
156
|
- Find the actor instance before the insert when an enqueue starts, and lock
|
data/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Solid Object Rails Actors elegantly fit anything where one identifiable thing mu
|
|
|
16
16
|
- Ticket holds and reservations
|
|
17
17
|
- Multiplayer games and shared rooms
|
|
18
18
|
- Shopping carts and checkout recovery
|
|
19
|
-
-
|
|
19
|
+
- Low-rate quotas and account limits
|
|
20
20
|
- Session expiration
|
|
21
21
|
- Job leases and workflows
|
|
22
22
|
- Connected devices
|
data/benchmark/support.rb
CHANGED
|
@@ -425,14 +425,8 @@ module SolidObjectsBenchmark
|
|
|
425
425
|
|
|
426
426
|
# @rbs () -> void
|
|
427
427
|
def migrate
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns"
|
|
431
|
-
require_relative "../db/migrate/20260915000000_add_solid_objects_effect_recoveries"
|
|
432
|
-
CreateSolidObjectsTables.new.migrate(:up)
|
|
433
|
-
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
|
|
434
|
-
RenameMessageDispatchColumns.new.migrate(:up)
|
|
435
|
-
AddSolidObjectsEffectRecoveries.new.migrate(:up)
|
|
428
|
+
require "solid_objects/schema_bootstrap"
|
|
429
|
+
SolidObjects::SchemaBootstrap.install
|
|
436
430
|
end
|
|
437
431
|
|
|
438
432
|
# @rbs () -> void
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class AddSolidObjectsAdministrationEvents < ActiveRecord::Migration[7.1]
|
|
4
|
+
# @rbs () -> void
|
|
5
|
+
def change
|
|
6
|
+
create_table SolidObjects.table_name(:administration_events) do |definition|
|
|
7
|
+
definition.string :action, null: false, limit: 64
|
|
8
|
+
definition.string :kind, null: false, limit: 32
|
|
9
|
+
definition.string :subject_id, limit: 191
|
|
10
|
+
definition.public_send(json_type, :filters)
|
|
11
|
+
definition.string :actor, limit: 255
|
|
12
|
+
definition.datetime :occurred_at, null: false, precision: 6
|
|
13
|
+
definition.timestamps precision: 6, null: false
|
|
14
|
+
|
|
15
|
+
definition.index [ :occurred_at, :id ], name: "idx_so_admin_events_occurred"
|
|
16
|
+
definition.index [ :kind, :subject_id ], name: "idx_so_admin_events_subject"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# @rbs () -> Symbol
|
|
23
|
+
def json_type
|
|
24
|
+
connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class AddSolidObjectsRedrives < ActiveRecord::Migration[7.1]
|
|
4
|
+
# @rbs () -> void
|
|
5
|
+
def change
|
|
6
|
+
create_table SolidObjects.table_name(:redrives), id: :string, limit: 64 do |definition|
|
|
7
|
+
definition.string :kind, null: false, limit: 32
|
|
8
|
+
definition.public_send(json_type, :filters, null: false)
|
|
9
|
+
definition.string :status, null: false, default: "running", limit: 32
|
|
10
|
+
definition.string :active_scope, limit: 191
|
|
11
|
+
definition.integer :moved, null: false, default: 0
|
|
12
|
+
definition.integer :move_limit
|
|
13
|
+
definition.string :actor, limit: 255
|
|
14
|
+
definition.datetime :started_at, null: false, precision: 6
|
|
15
|
+
definition.datetime :finished_at, precision: 6
|
|
16
|
+
definition.timestamps precision: 6, null: false
|
|
17
|
+
|
|
18
|
+
definition.index :active_scope, unique: true, name: "idx_so_redrives_active_scope"
|
|
19
|
+
definition.index [ :status, :started_at, :id ], name: "idx_so_redrives_poll"
|
|
20
|
+
definition.check_constraint "moved >= 0", name: "chk_so_redrives_moved"
|
|
21
|
+
definition.check_constraint "move_limit IS NULL OR move_limit > 0", name: "chk_so_redrives_limit"
|
|
22
|
+
definition.check_constraint "status IN ('running', 'completed', 'cancelled')", name: "chk_so_redrives_status"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# @rbs () -> Symbol
|
|
29
|
+
def json_type
|
|
30
|
+
connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
class AddSolidObjectsCompletedIdempotencyKeys < ActiveRecord::Migration[7.1]
|
|
4
|
+
# @rbs () -> void
|
|
5
|
+
def change
|
|
6
|
+
add_column SolidObjects.table_name(:instances),
|
|
7
|
+
:completed_idempotency_keys,
|
|
8
|
+
connection.adapter_name.match?(/postgres/i) ? :jsonb : :json
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -25,6 +25,14 @@ The interface supports:
|
|
|
25
25
|
|
|
26
26
|
MySQL uses polling or optional Redis. SQLite uses polling plus the in-process signal; multi-host SQLite is outside its supported operating model.
|
|
27
27
|
|
|
28
|
+
Selection is automatic. `wake_up_adapter` takes a name or an adapter and
|
|
29
|
+
defaults to `:automatic`, which prefers a configured Redis URL, then PostgreSQL
|
|
30
|
+
notifications, then polling. PostgreSQL is chosen only after a probe
|
|
31
|
+
notification arrives, because `LISTEN` does not survive a transaction pooler. A
|
|
32
|
+
requested adapter that the environment cannot provide polls and records why,
|
|
33
|
+
rather than claim a wake-up it cannot deliver. `SolidObjects.wake_up.capability`
|
|
34
|
+
reports the choice, and the doctor reports the same record.
|
|
35
|
+
|
|
28
36
|
The synchronous caller first attempts to claim and execute the actor locally,
|
|
29
37
|
so the normal path has no worker polling leg. When another process owns the
|
|
30
38
|
activation, coordination overhead from completion commit until the caller's
|
|
@@ -45,5 +53,7 @@ Timeout does not cancel durable work.
|
|
|
45
53
|
- Redis loss only increases latency and never loses durable work.
|
|
46
54
|
- Every adapter retains periodic polling to close startup, reconnect, and missed-message races.
|
|
47
55
|
- A process that returns `false` from a timed wait participates in backoff; an older custom adapter that returns `nil` keeps the fast cadence.
|
|
48
|
-
- A multi-process deployment
|
|
56
|
+
- A multi-process deployment whose installed adapter cannot cross processes trades idle database load for up to the current idle polling interval of notification latency and logs that topology once.
|
|
57
|
+
- A PostgreSQL deployment that configures nothing now pays one `NOTIFY` per enqueue after the commit and one listening connection per waiting thread, outside the pool.
|
|
58
|
+
- Selection runs once per process, under a lock, because the probe opens connections and waits.
|
|
49
59
|
- Notification payloads never contain actor arguments or results.
|
data/docs/architecture.md
CHANGED
|
@@ -72,7 +72,7 @@ through the client.
|
|
|
72
72
|
|
|
73
73
|
### Client and mailbox
|
|
74
74
|
|
|
75
|
-
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented.
|
|
75
|
+
The client finds or creates the actor instance and atomically allocates a sequence. It inserts one durable message-history row and one ready-membership row. It validates operations and JSON payloads before writing and enforces idempotency-key uniqueness, payload limits, and the per-actor mailbox cap. It also authorizes and coordinates actor destruction. Distributed rate limiting and global admission control are not implemented and are not planned here.
|
|
76
76
|
|
|
77
77
|
Message execution state is table membership, not a status column. The durable message remains for results, retention, and diagnostics. Only live work occupies `ready_messages` or `claimed_messages`, so completed history cannot inflate the polling index.
|
|
78
78
|
|
|
@@ -424,6 +424,42 @@ outer commit, and callers timing out on work they indirectly block.
|
|
|
424
424
|
waiting and immediately returns a `MessageReference`. Runtime workers process
|
|
425
425
|
it normally.
|
|
426
426
|
|
|
427
|
+
A caller that loses that reference rebuilds one. `SolidObjects.client.find_by`
|
|
428
|
+
answers a request id, which is unique across the table, and
|
|
429
|
+
`reference.find_by` answers an idempotency key, which is unique per instance.
|
|
430
|
+
Each lookup runs the authorization hook the original call ran, against the
|
|
431
|
+
stored operation and arguments, and answers `nil` for an absent row, an
|
|
432
|
+
unregistered actor, and a refused caller alike, so it cannot be used to ask
|
|
433
|
+
whether a request id exists. `MessageReference#outcome` reports the status, the
|
|
434
|
+
result, the persisted error, the rejection, and the attempt count. A result is
|
|
435
|
+
stored for `sync` delivery only.
|
|
436
|
+
|
|
437
|
+
An actor remembers the idempotency keys of its own finished turns. The executor
|
|
438
|
+
already writes the instance row in the transaction that completes, rejects, or
|
|
439
|
+
kills a turn, so the memory rides on a write that happens anyway. This is the
|
|
440
|
+
Orleans answer: a grain keeps its deduplication history in grain state rather
|
|
441
|
+
than in a separate tombstone table, which needs no second store, no second
|
|
442
|
+
write, and no separate retention. `reference.find_by(idempotency_key:)` raises
|
|
443
|
+
`MessagePruned` for a key the actor remembers and whose message retention
|
|
444
|
+
removed, and answers `nil` for a key no caller ever sent, so a client can tell
|
|
445
|
+
a lost result from a request that never arrived. An actor remembers the operation and original arguments beside each key, so
|
|
446
|
+
the pruned answer runs the same hook against the same operation and arguments that a lookup
|
|
447
|
+
of the surviving row would, and a caller the policy refuses reads `nil` whether
|
|
448
|
+
the message is pruned or never existed. Gating it on `snapshot` instead would
|
|
449
|
+
tell a caller who may read state, but not the operation, that the operation had
|
|
450
|
+
run.
|
|
451
|
+
Remembered arguments count toward the serialized memory limit and remain until
|
|
452
|
+
the entry is evicted or the instance is removed. Entries from older versions
|
|
453
|
+
that lack arguments return absence after pruning because their original
|
|
454
|
+
authorization cannot be reproduced.
|
|
455
|
+
|
|
456
|
+
`retained_idempotency_keys` bounds the memory and defaults to 64 keys for each
|
|
457
|
+
actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB,
|
|
458
|
+
because an idempotency key has no length limit on every adapter and the memory
|
|
459
|
+
outlives the message row. An actor drops its oldest keys until the list fits. Only a lookup by idempotency key can make the distinction. A request id
|
|
460
|
+
is generated by the runtime rather than by the caller, so no actor remembers
|
|
461
|
+
one, and `client.find_by(request_id:)` answers `nil` in both cases.
|
|
462
|
+
|
|
427
463
|
An executing caller receives an inline after-commit callback error even though
|
|
428
464
|
the turn committed. An independently waiting caller observes the durable
|
|
429
465
|
result and may return before that callback raises in the worker. Completed
|
|
@@ -750,11 +786,11 @@ Enqueue counts unfinished rows under the locked actor instance and rejects with
|
|
|
750
786
|
|
|
751
787
|
### Per-actor rate limits
|
|
752
788
|
|
|
753
|
-
|
|
789
|
+
This runtime supplies the mailbox cap. Distributed token buckets and time-window counters are not planned here, because a request-path limiter is hot and loss-tolerant while every invocation writes one permanent message row. Solid Objects Pro answers that shape with grouped and ephemeral operations, which [fit](fit.md) describes.
|
|
754
790
|
|
|
755
791
|
### Global enqueue limits
|
|
756
792
|
|
|
757
|
-
Global admission hooks are not implemented
|
|
793
|
+
Global admission hooks are not implemented and are not planned here, for the same reason as per-actor rate limits. A strict global counter would also be a contention hotspot. Reject on database health or application policy in front of the actor instead.
|
|
758
794
|
|
|
759
795
|
### Payload size
|
|
760
796
|
|
|
@@ -867,9 +903,9 @@ All backends use unique identity and sequence constraints, short transactions, a
|
|
|
867
903
|
12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease.
|
|
868
904
|
13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped.
|
|
869
905
|
14. **How does synchronous invocation work across processes?** The caller first tries to claim and execute the actor locally. If another process owns it, a wake-up adapter prompts a durable result query and bounded polling remains the fallback.
|
|
870
|
-
15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference. An enqueue timeout leaves no message. Running Ruby code is not preempted.
|
|
906
|
+
15. **What happens after caller timeout?** A committed message continues and its eventual result can be recovered with the timeout's authorized message reference, or with `find_by` from the request id or the idempotency key when that reference is gone. An enqueue timeout leaves no message. Running Ruby code is not preempted.
|
|
871
907
|
16. **How are results cleaned up?** `prune_messages` deletes eligible terminal history in bounded batches after global or per-actor retention. It previews by default and preserves live work, dead letters, retry links, and unfinished outboxes.
|
|
872
|
-
17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields
|
|
908
|
+
17. **How are large mailboxes managed?** The implemented controls are the per-actor mailbox cap, payload caps, and fair activation yields. Rate and global admission controls are not planned here; Solid Objects Pro answers that shape.
|
|
873
909
|
18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically.
|
|
874
910
|
19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit.
|
|
875
911
|
20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers.
|
data/docs/dashboard.md
CHANGED
|
@@ -149,6 +149,16 @@ actor class that no longer exists, a full mailbox, a payload over the cap. The
|
|
|
149
149
|
dashboard renders the dead letter again with the reason and a 422 status,
|
|
150
150
|
rather than failing the request.
|
|
151
151
|
|
|
152
|
+
Dead effects and broadcasts have the same API, which the dashboard does not yet
|
|
153
|
+
surface. `SolidObjects.dead_letters.effects` and
|
|
154
|
+
`SolidObjects.dead_letters.broadcasts` read and retry their own kind, and
|
|
155
|
+
`redrive` moves a whole scope as a durable task. See
|
|
156
|
+
[Operations](operations.md) for both.
|
|
157
|
+
|
|
158
|
+
Every retry and every redrive transition writes one row to
|
|
159
|
+
`solid_objects_administration_events`, holding the action, the kind, the
|
|
160
|
+
subject, and the identity that asked for it.
|
|
161
|
+
|
|
152
162
|
**Pause an instance** sets `paused_at`, and the activation manager stops
|
|
153
163
|
claiming that identity. Two consequences matter:
|
|
154
164
|
|
data/docs/fit.md
CHANGED
|
@@ -68,6 +68,12 @@ a presence signal, or a view count. Reactive projections materialize a read
|
|
|
68
68
|
model from the durable broadcast outbox, so request-path reads stop competing
|
|
69
69
|
with mailbox work.
|
|
70
70
|
|
|
71
|
+
Distributed per-actor rate limits, global admission control, and cache-capacity
|
|
72
|
+
eviction are answered there rather than in this gem. Each one is hot and
|
|
73
|
+
request-critical, and this gem writes one permanent message row for every
|
|
74
|
+
invocation, so the cost model above rules out a limiter that checks on the
|
|
75
|
+
request path. They are not open roadmap items here.
|
|
76
|
+
|
|
71
77
|
## Cost model
|
|
72
78
|
|
|
73
79
|
Every synchronous or asynchronous invocation:
|
data/docs/operations.md
CHANGED
|
@@ -25,6 +25,22 @@ reports a failed or warned check rather than raising out of the command.
|
|
|
25
25
|
|
|
26
26
|
## Installing and upgrading
|
|
27
27
|
|
|
28
|
+
Solid Objects keeps actor state, message arguments, results, and the remembered
|
|
29
|
+
idempotency keys in JSON columns. Active Support decodes every one of them, and
|
|
30
|
+
`ActiveSupport::JSON.decode` raises with the `json` gem at 3.0.2:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
ArgumentError: wrong number of arguments (given 2, expected 1)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The failure is in Active Support rather than in Solid Objects, and it reaches
|
|
37
|
+
every JSON column in a Rails application. A new Rails 8.1 application resolves
|
|
38
|
+
`json` 3.0.2 today, so pin the 2.x series until Rails ships a fix:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
gem "json", "~> 2"
|
|
42
|
+
```
|
|
43
|
+
|
|
28
44
|
Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order
|
|
29
45
|
notes, then update the gem:
|
|
30
46
|
|
|
@@ -215,6 +231,8 @@ end
|
|
|
215
231
|
| `instance_retention_by_actor_type` | `{}`; instances never expire unless listed |
|
|
216
232
|
| `process_retention` | 7 days |
|
|
217
233
|
| `prune_batch_size` | 1,000 |
|
|
234
|
+
| `retained_idempotency_keys` | 64 |
|
|
235
|
+
| `retained_idempotency_keys_bytes` | 16 KB |
|
|
218
236
|
| `worker_count` | 1 |
|
|
219
237
|
| `effect_worker_count` | 1 |
|
|
220
238
|
| `broadcast_worker_count` | 1 |
|
|
@@ -245,13 +263,27 @@ to `idle_polling_interval`, which defaults to one second. Actor workers clamp
|
|
|
245
263
|
the ceiling to `lease_renewal_interval` while they may hold cached activations.
|
|
246
264
|
Set the fast and idle values equal for a fixed cadence.
|
|
247
265
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
current
|
|
266
|
+
Solid Objects selects a wake-up adapter on first use. `wake_up_adapter` defaults
|
|
267
|
+
to `:automatic`, which prefers `SOLID_OBJECTS_REDIS_URL`, then PostgreSQL
|
|
268
|
+
notifications, then polling. `SolidObjects.wake_up.capability` and the
|
|
269
|
+
`wake_up` doctor check report what was installed, whether it crosses processes,
|
|
270
|
+
its measured floor, and why.
|
|
271
|
+
|
|
272
|
+
The in-process signal interrupts waits only in the current Ruby process. When a
|
|
273
|
+
live process record shows that the database is shared across processes and the
|
|
274
|
+
installed adapter does not cross them, the runtime logs
|
|
275
|
+
`solid_objects.polling_only_cross_process_wake_up` once. Newly committed work
|
|
276
|
+
can then wait up to the current idle polling interval.
|
|
277
|
+
|
|
278
|
+
On PostgreSQL, selection proves the path first: it listens on a probe channel,
|
|
279
|
+
notifies it from a second connection, and waits for the notification. A probe
|
|
280
|
+
that does not arrive logs `solid_objects.wake_up.pooled_session` once and falls
|
|
281
|
+
back to polling, because `LISTEN` does not survive a transaction pooler such as
|
|
282
|
+
PgBouncer. A requested adapter that the environment cannot provide, such as
|
|
283
|
+
`:postgresql` on MySQL or `:redis` without `SOLID_OBJECTS_REDIS_URL`, logs
|
|
284
|
+
`solid_objects.wake_up.unavailable` once and polls rather than claim a
|
|
285
|
+
cross-process wake-up that cannot happen. Only an unknown name is refused, and
|
|
286
|
+
`configure` refuses it at boot.
|
|
255
287
|
|
|
256
288
|
The warning excludes process rows with the current hostname and PID. It can
|
|
257
289
|
therefore appear during a rolling deployment or restart overlap when an older
|
|
@@ -268,6 +300,95 @@ interval, and current interval. The polling-only warning is also emitted as
|
|
|
268
300
|
adapters should return `true` for a notification and `false` for a timeout; an
|
|
269
301
|
older adapter that returns `nil` remains compatible and keeps the fast cadence.
|
|
270
302
|
|
|
303
|
+
## Dead letters, retry, and redrive
|
|
304
|
+
|
|
305
|
+
A message that exhausts its attempts becomes a dead letter. An effect or a
|
|
306
|
+
broadcast that exhausts its attempts stays in its own table with
|
|
307
|
+
`status = 'dead'`. All three are read and retried through one receiver, which
|
|
308
|
+
carries the kind:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
SolidObjects.dead_letters.all(authorization_context: current_admin)
|
|
312
|
+
SolidObjects.dead_letters.retry(dead_letter_id, authorization_context: current_admin)
|
|
313
|
+
|
|
314
|
+
SolidObjects.dead_letters.effects.all(authorization_context: current_admin)
|
|
315
|
+
SolidObjects.dead_letters.effects.retry(effect_id, authorization_context: current_admin)
|
|
316
|
+
SolidObjects.dead_letters.broadcasts.retry(broadcast_id, authorization_context: current_admin)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
An effect or broadcast retry returns the row to pending with a zero attempt
|
|
320
|
+
count, no claim, and immediate availability. It keeps the stable id, so a
|
|
321
|
+
handler that deduplicates on `effect_id` still sees the same key. An effect is
|
|
322
|
+
at-least-once by contract, so a retried effect can run twice.
|
|
323
|
+
|
|
324
|
+
Retry acts only on a dead row. A row that is pending, processing, or completed
|
|
325
|
+
comes back unchanged, so pressing a button twice cannot double-enqueue and
|
|
326
|
+
cannot take a row away from a worker that holds it.
|
|
327
|
+
|
|
328
|
+
An incident produces dead rows in the hundreds, so a scope also answers
|
|
329
|
+
`redrive`:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
task = SolidObjects.dead_letters.effects.redrive(
|
|
333
|
+
actor_type: "payments",
|
|
334
|
+
failed_after: 6.hours.ago,
|
|
335
|
+
limit: 5_000,
|
|
336
|
+
authorization_context: current_admin
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
task.id # => "redrive_..."
|
|
340
|
+
task.status # => "running"
|
|
341
|
+
task.moved # => 412
|
|
342
|
+
task.remaining # => 4_588
|
|
343
|
+
|
|
344
|
+
task.cancel(authorization_context: current_admin)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`redrive` returns at once. The task is durable, and the supervisor advances one
|
|
348
|
+
bounded batch per pass, so a redrive of thousands of rows never holds a
|
|
349
|
+
transaction longer than one batch. `redrive_batch_size` defaults to 100 and
|
|
350
|
+
`redrive_batch_pause` to 0.05 seconds.
|
|
351
|
+
|
|
352
|
+
A redrive moves the rows that were already dead when it started. A row that
|
|
353
|
+
fails again lands back in the same scope, and without that bound a task whose
|
|
354
|
+
handler is still broken would move it forever.
|
|
355
|
+
|
|
356
|
+
A redrive is idempotent over its scope and its filters. Starting the same one
|
|
357
|
+
while it runs returns the running task rather than a second one, which a
|
|
358
|
+
dashboard button an operator can press twice needs. A different scope or a
|
|
359
|
+
different filter starts its own task, and the same scope can be redriven again
|
|
360
|
+
once the first task finishes.
|
|
361
|
+
|
|
362
|
+
Read tasks back with `SolidObjects.redrives`:
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
SolidObjects.redrives.find(task.id, authorization_context: current_admin)
|
|
366
|
+
SolidObjects.redrives.all(status: :running, authorization_context: current_admin)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
A running task reports what is left to move rather than a stored estimate,
|
|
370
|
+
because rows die and are retried while it runs.
|
|
371
|
+
|
|
372
|
+
Retry, redrive, and cancel each go through `authorize_administration` under
|
|
373
|
+
their own resource name: `dead_letters`, `effect_dead_letters`,
|
|
374
|
+
`broadcast_dead_letters`, and `redrives`. Every retry and every task transition
|
|
375
|
+
writes one row to `solid_objects_administration_events`, holding the action, the
|
|
376
|
+
kind, the subject, the identity, and when it happened. The identity comes from
|
|
377
|
+
`administration_identity`, which receives the authorization context the caller
|
|
378
|
+
passed and defaults to its `to_s`.
|
|
379
|
+
|
|
380
|
+
An event records an authorized press, not a state transition. Pressing retry
|
|
381
|
+
twice writes two rows, because an operator did two things and a log that shows
|
|
382
|
+
one cannot answer who pressed what. The row the event names carries the outcome.
|
|
383
|
+
A refused caller writes nothing, and a retry that raises after the lookup writes
|
|
384
|
+
nothing, because the event shares the transaction with the work. The redrive
|
|
385
|
+
transitions are different: `redrive.start`, `redrive.finish`, and
|
|
386
|
+
`redrive.cancel` are written only when the task actually changes.
|
|
387
|
+
|
|
388
|
+
Automatic redrive on a schedule is deliberately absent. A dead row means a
|
|
389
|
+
person decided something, and these APIs give that person an alternative to an
|
|
390
|
+
`UPDATE` against a runtime table.
|
|
391
|
+
|
|
271
392
|
## Graceful shutdown
|
|
272
393
|
|
|
273
394
|
The supervisor requests shutdown, stops new claims, lets active loops return,
|
|
@@ -414,6 +535,8 @@ SolidObjects.configure do |configuration|
|
|
|
414
535
|
}
|
|
415
536
|
configuration.process_retention = 7.days
|
|
416
537
|
configuration.prune_batch_size = 1_000
|
|
538
|
+
configuration.retained_idempotency_keys = 64
|
|
539
|
+
configuration.retained_idempotency_keys_bytes = 16.kilobytes
|
|
417
540
|
end
|
|
418
541
|
```
|
|
419
542
|
|
|
@@ -440,6 +563,29 @@ broadcasts, and other message-owned rows. Choose a cutoff longer than every
|
|
|
440
563
|
`sync` timeout because a caller whose result row disappears can no longer
|
|
441
564
|
observe it.
|
|
442
565
|
|
|
566
|
+
`find_by` reads the same rows, so a lookup answers only while the message it
|
|
567
|
+
names survives retention. A lookup by idempotency key still tells the two cases
|
|
568
|
+
apart after pruning, because the actor remembers the keys of its own last
|
|
569
|
+
`retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key
|
|
570
|
+
the actor remembers and answers `nil` for a key no caller ever sent. The actor
|
|
571
|
+
remembers the operation and original arguments beside each key, so the pruned answer runs the same
|
|
572
|
+
authorization the surviving row would. Raise
|
|
573
|
+
`retained_idempotency_keys` above the default of 64 when an actor finishes more
|
|
574
|
+
keyed turns than that inside the window in which a caller may retry. A lookup
|
|
575
|
+
by request id answers `nil` in both cases, so a caller that must tell them apart
|
|
576
|
+
sends its own idempotency key.
|
|
577
|
+
|
|
578
|
+
Remembered arguments count toward the serialized memory limit and remain until
|
|
579
|
+
the entry is evicted or the instance is removed. Entries from older versions
|
|
580
|
+
that lack arguments return absence after pruning because their original
|
|
581
|
+
authorization cannot be reproduced.
|
|
582
|
+
|
|
583
|
+
`retained_idempotency_keys_bytes` bounds the serialized memory as well, because
|
|
584
|
+
an idempotency key has no length limit on every adapter and the memory outlives
|
|
585
|
+
the message row. An actor drops its oldest keys until the list fits, so a key
|
|
586
|
+
long enough to fill the limit by itself is never remembered and its lookup
|
|
587
|
+
answers `nil` rather than raising.
|
|
588
|
+
|
|
443
589
|
Actor expiration is disabled by default. `prune_instances` considers only
|
|
444
590
|
actor types listed in `instance_retention_by_actor_type`, excludes active or
|
|
445
591
|
paused actors, and preserves ready/claimed mailbox work, scheduled reminders,
|