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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +151 -0
  3. data/README.md +1 -1
  4. data/app/models/solid_objects/administration_event.rb +7 -0
  5. data/app/models/solid_objects/redrive.rb +7 -0
  6. data/benchmark/support.rb +2 -8
  7. data/db/migrate/20260922000000_add_solid_objects_administration_events.rb +26 -0
  8. data/db/migrate/20260922000001_add_solid_objects_redrives.rb +32 -0
  9. data/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb +10 -0
  10. data/docs/adr/0011-wake-up-strategy.md +11 -1
  11. data/docs/architecture.md +41 -5
  12. data/docs/dashboard.md +10 -0
  13. data/docs/fit.md +6 -0
  14. data/docs/operations.md +153 -7
  15. data/docs/realtime.md +32 -26
  16. data/docs/roadmap.md +60 -32
  17. data/examples/at_least_once/boot.rb +2 -8
  18. data/lib/solid_objects/activation.rb +13 -6
  19. data/lib/solid_objects/actor.rb +134 -8
  20. data/lib/solid_objects/actor_snapshot.rb +8 -5
  21. data/lib/solid_objects/administration_audit.rb +30 -0
  22. data/lib/solid_objects/client.rb +81 -0
  23. data/lib/solid_objects/configuration.rb +47 -2
  24. data/lib/solid_objects/context.rb +10 -4
  25. data/lib/solid_objects/dead_letter_manager.rb +42 -6
  26. data/lib/solid_objects/dead_letter_scope.rb +143 -0
  27. data/lib/solid_objects/dead_row.rb +15 -0
  28. data/lib/solid_objects/doctor.rb +24 -5
  29. data/lib/solid_objects/errors.rb +11 -0
  30. data/lib/solid_objects/executor.rb +41 -3
  31. data/lib/solid_objects/message_reference.rb +29 -9
  32. data/lib/solid_objects/outcome.rb +31 -0
  33. data/lib/solid_objects/process_registry.rb +3 -1
  34. data/lib/solid_objects/redrive_manager.rb +130 -0
  35. data/lib/solid_objects/redrive_runner.rb +60 -0
  36. data/lib/solid_objects/redrive_task.rb +14 -0
  37. data/lib/solid_objects/reference.rb +5 -0
  38. data/lib/solid_objects/schema_bootstrap.rb +27 -0
  39. data/lib/solid_objects/supervisor.rb +43 -0
  40. data/lib/solid_objects/test_helper.rb +2 -0
  41. data/lib/solid_objects/version.rb +1 -1
  42. data/lib/solid_objects/wake_up.rb +12 -0
  43. data/lib/solid_objects/wake_up_adapters/postgresql.rb +12 -0
  44. data/lib/solid_objects/wake_up_adapters/redis.rb +12 -0
  45. data/lib/solid_objects/wake_up_adapters.rb +206 -11
  46. data/lib/solid_objects/wake_up_capability.rb +15 -0
  47. data/lib/solid_objects.rb +46 -3
  48. data/sig/generated/lib/solid_objects/activation.rbs +7 -0
  49. data/sig/generated/lib/solid_objects/actor.rbs +93 -2
  50. data/sig/generated/lib/solid_objects/administration_audit.rbs +11 -0
  51. data/sig/generated/lib/solid_objects/client.rbs +12 -0
  52. data/sig/generated/lib/solid_objects/configuration.rbs +54 -28
  53. data/sig/generated/lib/solid_objects/context.rbs +11 -6
  54. data/sig/generated/lib/solid_objects/dead_letter_manager.rbs +9 -0
  55. data/sig/generated/lib/solid_objects/dead_letter_scope.rbs +58 -0
  56. data/sig/generated/lib/solid_objects/dead_row.rbs +30 -0
  57. data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
  58. data/sig/generated/lib/solid_objects/errors.rbs +8 -0
  59. data/sig/generated/lib/solid_objects/executor.rbs +9 -0
  60. data/sig/generated/lib/solid_objects/message_reference.rbs +8 -0
  61. data/sig/generated/lib/solid_objects/outcome.rbs +52 -0
  62. data/sig/generated/lib/solid_objects/redrive_manager.rbs +43 -0
  63. data/sig/generated/lib/solid_objects/redrive_runner.rbs +22 -0
  64. data/sig/generated/lib/solid_objects/redrive_task.rbs +33 -0
  65. data/sig/generated/lib/solid_objects/reference.rbs +5 -2
  66. data/sig/generated/lib/solid_objects/schema_bootstrap.rbs +11 -0
  67. data/sig/generated/lib/solid_objects/supervisor.rbs +15 -0
  68. data/sig/generated/lib/solid_objects/wake_up.rbs +5 -0
  69. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +5 -0
  70. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +5 -0
  71. data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +75 -9
  72. data/sig/generated/lib/solid_objects/wake_up_capability.rbs +28 -0
  73. data/sig/generated/lib/solid_objects.rbs +14 -2
  74. data/sig/generated/models/solid_objects/administration_event.rbs +6 -0
  75. data/sig/generated/models/solid_objects/redrive.rbs +6 -0
  76. data/sig/public/reminder_payload.rbs +3 -0
  77. metadata +28 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55b365960ffe5f49934eb8914e35d546af1de656e158477977caf8c75db19fcd
4
- data.tar.gz: fcea896079cc1f85eb60e6cbc13d080c597a8074a077cf32a1e34142b5055886
3
+ metadata.gz: 2d267d861c25f682b3e45208131b9c115b49710ae43fb499223c6d63f48519a0
4
+ data.tar.gz: 1acc3bb415c57e9072f231fc5e37ed37ce8bab4a9ba5fc894a7dbe5453839d8e
5
5
  SHA512:
6
- metadata.gz: 6c21e96eb84ebbaf519c8860b79d952f3daa5d647f6ca0de6d35527b5b24bab53fd086c35fb57446a1173798f4c2849034d368415ff4afaacccedb509e768f23
7
- data.tar.gz: f11760f2484478b62ad93cf18327aaa9f5a9d53b7b451c8f1233a479854a3f50b75b369f8559d06295794ea19cad30696088889b3258a541c4003f5775cec2fc
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
- - Rate limits and account quotas
19
+ - Low-rate quotas and account limits
20
20
  - Session expiration
21
21
  - Job leases and workflows
22
22
  - Connected devices
@@ -0,0 +1,7 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class AdministrationEvent < Record
5
+ self.table_name = SolidObjects.table_name(:administration_events)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class Redrive < Record
5
+ self.table_name = SolidObjects.table_name(:redrives)
6
+ end
7
+ end
data/benchmark/support.rb CHANGED
@@ -425,14 +425,8 @@ module SolidObjectsBenchmark
425
425
 
426
426
  # @rbs () -> void
427
427
  def migrate
428
- require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
429
- require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
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 without an adapter trades idle database load for up to the current idle polling interval of notification latency and logs that topology once.
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
- The initial implementation supplies the mailbox cap. Distributed token buckets or time-window counters are a hardening milestone.
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. A future hook can reject based on database health or application policy without introducing a strict global counter as a contention hotspot.
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; rate and global admission controls remain roadmap work.
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
- The default wake-up interrupts waits only in the current Ruby process. When a
249
- live process record shows that the database is shared across processes and no
250
- adapter is configured, the runtime logs
251
- `solid_objects.polling_only_cross_process_wake_up` once. Configure
252
- `WakeUpAdapters::Postgresql` or `WakeUpAdapters::Redis` when separate processes
253
- need prompt delivery. Without one, newly committed work can wait up to the
254
- current idle polling interval.
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,