diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c34a76..3c049c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,71 @@ # Changelog -## Unreleased - +## 0.16.0 - 2026-09-23 + +- Find a message whose reference a caller lost. + `SolidObjects.client.find_by(request_id:)` answers a request id, which is + unique across the table, and `reference.find_by(idempotency_key:)` answers a + key, which is unique per instance, so the receiver supplies the scope the key + needs. Naming neither key, naming both, or naming an idempotency key without a + reference raises `ArgumentError`. +- Authorize every lookup with the hook the original call ran, against the stored + operation and arguments, because a request id is not a capability. An absent + row, an actor this process no longer registers, and a caller the policy + refuses all return `nil`, so a lookup cannot be used to ask whether a request + id exists. +- Add `MessageReference#outcome`, which reports the status, the result, the + persisted error, the rejection, and the attempt count, so a terminal failure + answers as well as a success. A result is stored for `sync` delivery only, so + an asynchronous message reports its status and error and no result. +- Tell a pruned message from one that never existed. An actor remembers the + idempotency keys of its own finished turns, the way an Orleans grain keeps + its deduplication history in grain state, so the memory needs no second + store and no second write. `reference.find_by(idempotency_key:)` raises + `SolidObjects::MessagePruned` for a key the actor remembers and whose message + retention removed, and still answers `nil` for a key no caller ever sent. + An actor remembers the operation beside each key, so the pruned answer runs + the same hook against the same operation a lookup of the surviving row would, + and a caller the policy refuses reads `nil` for both. Gating it on `snapshot` + would have told a caller who may read state, but not the operation, that the + operation had run. + `retained_idempotency_keys` bounds the memory and defaults to 64 keys for + each actor. A lookup by request id cannot make the distinction, because the + runtime, not the caller, generates a request id and no actor remembers one. + `retained_idempotency_keys_bytes` bounds the serialized memory as well, + because an idempotency key has no length limit on every adapter and the memory + outlives the message row. An actor drops its oldest keys until the list fits, + so a key long enough to fill the limit by itself is never remembered. +- Add `db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb`, + which adds `instances.completed_idempotency_keys` as `jsonb` on PostgreSQL and + `json` elsewhere. An application installs it with + `bin/rails solid_objects:install:migrations` and runs it before it upgrades a + worker, because the executor writes the column on every finished turn. The + doctor now reports the column as missing when it is not installed. +- Apply migrations through `SolidObjects::SchemaBootstrap`, which reads + `db/migrate`. Seven scripts each carried a hand-copied migration list, and + three of them applied an incomplete schema. A test fails if any script names a + migration class again. +- Report a half-applied migration in `solid_objects doctor`. The column list + omitted `instances.state_revision`, `messages.operation`, + `effects.success_operation`, `effects.failure_operation`, and + `dead_letters.operation`, so an application that skipped a migration read as + healthy and found out from a worker crash. A test fails when the list does not + name a column that a migration after the first adds. + +- List a dead effect or broadcast as a `SolidObjects::DeadRow` rather than as + an Active Record row. `all` returned rows whose `id` was the primary key while + `retry` reads `effect_id` or `broadcast_id`, so the obvious + `scope.retry(scope.all.first.id)` raised `ActiveRecord::RecordNotFound`. + `DeadRow#id` is now the value `retry` accepts, which is what the TypeScript + runtime has always returned. `dead` still answers the relation for a caller + that wants to scope it further. +- Raise a load error rather than report an unreachable database. Wake-up + selection rescued every exception, so a `NameError` from an unloaded model + read as "the database could not be reached" and downgraded the process to + in-process signalling. It now rescues database, system call, and IO errors + only. +- Note that `json` 3.0.2 breaks `ActiveSupport::JSON.decode`, and therefore + every JSON column, in [docs/operations.md](docs/operations.md). - Retry a dead effect or broadcast. `SolidObjects.dead_letters` keeps its message meaning and answers `effects` and `broadcasts`, so the kind rides on the receiver. `retry` returns a dead row to pending with a zero attempt count diff --git a/Gemfile.lock b/Gemfile.lock index 0c0f2e8..42ee6f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.15.2) + solid_objects (0.16.0) actioncable (>= 7.1) actionpack (>= 7.1) actionview (>= 7.1) @@ -384,7 +384,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_objects (0.15.2) + solid_objects (0.16.0) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b diff --git a/README.md b/README.md index 7322961..7dcda16 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Solid Object Rails Actors elegantly fit anything where one identifiable thing mu - Ticket holds and reservations - Multiplayer games and shared rooms - Shopping carts and checkout recovery -- Rate limits and account quotas +- Low-rate quotas and account limits - Session expiration - Job leases and workflows - Connected devices diff --git a/benchmark/support.rb b/benchmark/support.rb index 30136e4..ec7eda0 100644 --- a/benchmark/support.rb +++ b/benchmark/support.rb @@ -425,14 +425,8 @@ def establish_connection # @rbs () -> void def migrate - require_relative "../db/migrate/20260805000000_create_solid_objects_tables" - require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" - require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns" - require_relative "../db/migrate/20260915000000_add_solid_objects_effect_recoveries" - CreateSolidObjectsTables.new.migrate(:up) - AddStateRevisionToSolidObjectsInstances.new.migrate(:up) - RenameMessageDispatchColumns.new.migrate(:up) - AddSolidObjectsEffectRecoveries.new.migrate(:up) + require "solid_objects/schema_bootstrap" + SolidObjects::SchemaBootstrap.install end # @rbs () -> void diff --git a/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb b/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb new file mode 100644 index 0000000..251559f --- /dev/null +++ b/db/migrate/20260923000000_add_solid_objects_completed_idempotency_keys.rb @@ -0,0 +1,10 @@ +# rbs_inline: enabled + +class AddSolidObjectsCompletedIdempotencyKeys < ActiveRecord::Migration[7.1] + # @rbs () -> void + def change + add_column SolidObjects.table_name(:instances), + :completed_idempotency_keys, + connection.adapter_name.match?(/postgres/i) ? :jsonb : :json + end +end diff --git a/docs/architecture.md b/docs/architecture.md index 2936787..845a388 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ through the client. ### Client and mailbox -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. +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. 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. @@ -424,6 +424,42 @@ outer commit, and callers timing out on work they indirectly block. waiting and immediately returns a `MessageReference`. Runtime workers process it normally. +A caller that loses that reference rebuilds one. `SolidObjects.client.find_by` +answers a request id, which is unique across the table, and +`reference.find_by` answers an idempotency key, which is unique per instance. +Each lookup runs the authorization hook the original call ran, against the +stored operation and arguments, and answers `nil` for an absent row, an +unregistered actor, and a refused caller alike, so it cannot be used to ask +whether a request id exists. `MessageReference#outcome` reports the status, the +result, the persisted error, the rejection, and the attempt count. A result is +stored for `sync` delivery only. + +An actor remembers the idempotency keys of its own finished turns. The executor +already writes the instance row in the transaction that completes, rejects, or +kills a turn, so the memory rides on a write that happens anyway. This is the +Orleans answer: a grain keeps its deduplication history in grain state rather +than in a separate tombstone table, which needs no second store, no second +write, and no separate retention. `reference.find_by(idempotency_key:)` raises +`MessagePruned` for a key the actor remembers and whose message retention +removed, and answers `nil` for a key no caller ever sent, so a client can tell +a lost result from a request that never arrived. An actor remembers the operation and original arguments beside each key, so +the pruned answer runs the same hook against the same operation and arguments that a lookup +of the surviving row would, and a caller the policy refuses reads `nil` whether +the message is pruned or never existed. Gating it on `snapshot` instead would +tell a caller who may read state, but not the operation, that the operation had +run. +Remembered arguments count toward the serialized memory limit and remain until +the entry is evicted or the instance is removed. Entries from older versions +that lack arguments return absence after pruning because their original +authorization cannot be reproduced. + +`retained_idempotency_keys` bounds the memory and defaults to 64 keys for each +actor, and `retained_idempotency_keys_bytes` bounds its serialized size at 16 KB, +because an idempotency key has no length limit on every adapter and the memory +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 +is generated by the runtime rather than by the caller, so no actor remembers +one, and `client.find_by(request_id:)` answers `nil` in both cases. + An executing caller receives an inline after-commit callback error even though the turn committed. An independently waiting caller observes the durable 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 ### Per-actor rate limits -The initial implementation supplies the mailbox cap. Distributed token buckets or time-window counters are a hardening milestone. +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. ### Global enqueue limits -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. +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. ### Payload size @@ -867,9 +903,9 @@ All backends use unique identity and sequence constraints, short transactions, a 12. **How are leases renewed?** Conditional database update by instance, owner, generation, and unexpired lease. 13. **How does graceful shutdown work?** Stop claims, finish current turn within timeout, release cached leases, stop heartbeat, mark process stopped. 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. -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. +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. 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. -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. +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. 18. **How are completed messages pruned?** Operators schedule the dry-run-reviewed `prune_messages --execute` command. Solid Objects does not run deletion automatically. 19. **How are state migrations performed?** Explicit one-step actor migrations on activation, persisted only with a successful fenced commit. 20. **What happens during rolling deploys?** Newer state can make old workers incompatible; deploys must preserve backward readability or drain old workers. diff --git a/docs/fit.md b/docs/fit.md index 84ff868..b3be31b 100644 --- a/docs/fit.md +++ b/docs/fit.md @@ -68,6 +68,12 @@ a presence signal, or a view count. Reactive projections materialize a read model from the durable broadcast outbox, so request-path reads stop competing with mailbox work. +Distributed per-actor rate limits, global admission control, and cache-capacity +eviction are answered there rather than in this gem. Each one is hot and +request-critical, and this gem writes one permanent message row for every +invocation, so the cost model above rules out a limiter that checks on the +request path. They are not open roadmap items here. + ## Cost model Every synchronous or asynchronous invocation: diff --git a/docs/operations.md b/docs/operations.md index f11563f..df585c4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -25,6 +25,22 @@ reports a failed or warned check rather than raising out of the command. ## Installing and upgrading +Solid Objects keeps actor state, message arguments, results, and the remembered +idempotency keys in JSON columns. Active Support decodes every one of them, and +`ActiveSupport::JSON.decode` raises with the `json` gem at 3.0.2: + +``` +ArgumentError: wrong number of arguments (given 2, expected 1) +``` + +The failure is in Active Support rather than in Solid Objects, and it reaches +every JSON column in a Rails application. A new Rails 8.1 application resolves +`json` 3.0.2 today, so pin the 2.x series until Rails ships a fix: + +```ruby +gem "json", "~> 2" +``` + Review [CHANGELOG.md](CHANGELOG.md) for compatibility and deployment-order notes, then update the gem: @@ -215,6 +231,8 @@ end | `instance_retention_by_actor_type` | `{}`; instances never expire unless listed | | `process_retention` | 7 days | | `prune_batch_size` | 1,000 | +| `retained_idempotency_keys` | 64 | +| `retained_idempotency_keys_bytes` | 16 KB | | `worker_count` | 1 | | `effect_worker_count` | 1 | | `broadcast_worker_count` | 1 | @@ -517,6 +535,8 @@ SolidObjects.configure do |configuration| } configuration.process_retention = 7.days configuration.prune_batch_size = 1_000 + configuration.retained_idempotency_keys = 64 + configuration.retained_idempotency_keys_bytes = 16.kilobytes end ``` @@ -543,6 +563,29 @@ broadcasts, and other message-owned rows. Choose a cutoff longer than every `sync` timeout because a caller whose result row disappears can no longer observe it. +`find_by` reads the same rows, so a lookup answers only while the message it +names survives retention. A lookup by idempotency key still tells the two cases +apart after pruning, because the actor remembers the keys of its own last +`retained_idempotency_keys` finished turns: it raises `MessagePruned` for a key +the actor remembers and answers `nil` for a key no caller ever sent. The actor +remembers the operation and original arguments beside each key, so the pruned answer runs the same +authorization the surviving row would. Raise +`retained_idempotency_keys` above the default of 64 when an actor finishes more +keyed turns than that inside the window in which a caller may retry. A lookup +by request id answers `nil` in both cases, so a caller that must tell them apart +sends its own idempotency key. + +Remembered arguments count toward the serialized memory limit and remain until +the entry is evicted or the instance is removed. Entries from older versions +that lack arguments return absence after pruning because their original +authorization cannot be reproduced. + +`retained_idempotency_keys_bytes` bounds the serialized memory as well, because +an idempotency key has no length limit on every adapter and the memory outlives +the message row. An actor drops its oldest keys until the list fits, so a key +long enough to fill the limit by itself is never remembered and its lookup +answers `nil` rather than raising. + Actor expiration is disabled by default. `prune_instances` considers only actor types listed in `instance_retention_by_actor_type`, excludes active or paused actors, and preserves ready/claimed mailbox work, scheduled reminders, diff --git a/docs/roadmap.md b/docs/roadmap.md index 8fe2c27..db7e4f2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -58,7 +58,9 @@ cannot reject the subscription or stop its siblings - Reconciliation read APIs - Installation doctor, authorization reference, fit guide, and legacy-state - migration cookbook + migration cookbook. The doctor names every column that a migration after the + first adds, so a half-applied migration fails the schema check rather than + reaching a worker. A test holds the list to that rule - Database server verification: each adapter reports its version against a tested minimum, MySQL confirms Solid Objects tables use InnoDB, and the doctor warns rather than refusing to run on an untested server @@ -117,6 +119,15 @@ batched and unbatched components, an inert replay of an applied revision, cancellation of the request left in flight by the drop, incarnation ordering after a destroy and recreate, and payload delivery exactly once per revision +- Result lookup by request ID and by idempotency key, authorized with the hook + the original call ran and against the stored operation and arguments. An + actor remembers the idempotency keys of its own last `retained_idempotency_keys` + finished turns, written in the instance row the executor updates anyway, so a + lookup by key raises `MessagePruned` for a message that retention removed and + answers `nil` for a message that never existed. A lookup by request ID cannot + make that distinction, because the runtime generates a request ID and no + actor remembers one. Pruned lookups retain the original arguments for authorization + within the byte limit; older entries without arguments answer `nil` ## Partially implemented @@ -160,7 +171,11 @@ loads them in every process, and a rejected subscription reports which condition caused it instead of closing the socket silently. - Backpressure: mailbox/payload/state/result caps and fair yields exist; - distributed per-actor rate limits and global admission control do not. The + distributed per-actor rate limits, global admission control, and + cache-capacity eviction are not planned here. They are hot, request-path, and + loss-tolerant, so one durable ordered message per check is the wrong shape, + which [fit](fit.md) already says. Solid Objects Pro answers them with grouped + and ephemeral operations. The state cap is a limit rather than an operating point. `max_state_bytes` defaults to 5 MB, and committed throughput measured on SQLite falls about 53 times between an empty state and 1 MB of state, which `docs/benchmarks.md` @@ -197,13 +212,13 @@ ## Next milestones -1. Add result lookup by request ID and broader deadlock retry classification. -2. Add Turbo append intents. -3. Add distributed rate limits, global admission hooks, and cache-capacity - eviction. -4. Expand security scanning beyond the Brakeman scan, such as dependency +1. Broaden deadlock retry classification. +2. Add Turbo append intents. The renderer already emits the `append` action for + batch refreshes and payload delivery, so what remains is letting an + application direct one. +3. Expand security scanning beyond the Brakeman scan, such as dependency auditing and secret scanning. -5. Benchmark all workloads under documented hardware/database settings and +4. Benchmark all workloads under documented hardware/database settings and publish adapter-specific adoption measurements. Throughput, synchronous latency, query counts, and the three reactive delivery paths are measured on SQLite; adapter-specific and end-to-end browser measurements are not. diff --git a/examples/at_least_once/boot.rb b/examples/at_least_once/boot.rb index cd68a4c..c46dfb9 100644 --- a/examples/at_least_once/boot.rb +++ b/examples/at_least_once/boot.rb @@ -37,13 +37,7 @@ def self.call(database_path) # @rbs () -> void def self.migrate - require File.join(ROOT, "db/migrate/20260805000000_create_solid_objects_tables") - require File.join(ROOT, "db/migrate/20260806000000_add_state_revision_to_solid_objects_instances") - require File.join(ROOT, "db/migrate/20260813000000_rename_message_dispatch_columns") - require File.join(ROOT, "db/migrate/20260915000000_add_solid_objects_effect_recoveries") - CreateSolidObjectsTables.new.migrate(:up) - AddStateRevisionToSolidObjectsInstances.new.migrate(:up) - RenameMessageDispatchColumns.new.migrate(:up) - AddSolidObjectsEffectRecoveries.new.migrate(:up) + require "solid_objects/schema_bootstrap" + SolidObjects::SchemaBootstrap.install end end diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index faf9e9c..d74df6d 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -26,11 +26,13 @@ require "solid_objects/application_write_guard" require "solid_objects/actor" require "solid_objects/reference" +require "solid_objects/outcome" require "solid_objects/message_reference" require "solid_objects/administration_audit" require "solid_objects/redrive_task" require "solid_objects/redrive_manager" require "solid_objects/redrive_runner" +require "solid_objects/dead_row" require "solid_objects/dead_letter_scope" require "solid_objects/dead_letter_manager" require "solid_objects/message_pruner" @@ -228,7 +230,7 @@ def resolve_wake_up WakeUpAdapters.build(configuration.wake_up_adapter) rescue ArgumentError raise - rescue => error + rescue ActiveRecord::ActiveRecordError, SystemCallError, IOError => error unreachable_wake_up(error) end diff --git a/lib/solid_objects/client.rb b/lib/solid_objects/client.rb index 0db8ac1..20c4936 100644 --- a/lib/solid_objects/client.rb +++ b/lib/solid_objects/client.rb @@ -94,6 +94,45 @@ def wait(message_reference, timeout:, authorization_context: nil) raise SyncDiagnostics.new.database_contention_for(message_reference, timeout:) end + # @rbs (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference? + def find_by(reference: nil, request_id: nil, idempotency_key: nil, authorization_context: nil) + unless [ request_id, idempotency_key ].compact.one? + raise ArgumentError, "find_by expects exactly one of request_id: or idempotency_key:" + end + if idempotency_key && reference.nil? + raise ArgumentError, "find_by with idempotency_key: requires reference:" + end + + if request_id + return readable_message( + Message.uncached { Message.find_by(request_id:) }, + authorization_context: + ) + end + + instance = Instance.find_by( + actor_type: reference.actor_type, + actor_id: reference.actor_id + ) + return nil unless instance + + message = Message.uncached { Message.find_by(instance_id: instance.id, idempotency_key:) } + return readable_message(message, authorization_context:) if message + + remembered = Array(instance.completed_idempotency_keys) + .find { |entry| entry["key"] == idempotency_key } + return nil unless remembered && remembered["arguments"].is_a?(Hash) + return nil unless authorized_to_invoke?( + actor_type: reference.actor_type, + actor_id: reference.actor_id, + operation: remembered["operation"], + arguments: remembered["arguments"], + authorization_context: + ) + + raise MessagePruned, idempotency_key + end + # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot def snapshot(reference, authorization_context: nil) SolidObjects.registry.fetch(reference.actor_type) @@ -164,6 +203,48 @@ def enqueue_sync(reference:, operation:, arguments:, idempotency_key:, timeout:) ) end + # @rbs (Message?, authorization_context: untyped) -> MessageReference? + def readable_message(message, authorization_context:) + return nil unless message + return nil unless authorized_to_read?(message, authorization_context:) + + MessageReference.from_message(message) + end + + # @rbs (Message, authorization_context: untyped) -> bool + def authorized_to_read?(message, authorization_context:) + authorized_to_invoke?( + actor_type: message.actor_type, + actor_id: message.actor_id, + operation: message.operation, + arguments: message.arguments, + authorization_context: + ) + end + + # @rbs (actor_type: String, actor_id: String, operation: String, arguments: Hash[String, untyped], authorization_context: untyped) -> bool + def authorized_to_invoke?(actor_type:, actor_id:, operation:, arguments:, authorization_context:) + actor_class = SolidObjects.registry.fetch(actor_type) + operation_symbol = operation.to_sym + query = actor_class.definition.queries.key?(operation_symbol) + return false unless query || actor_class.definition.messages.key?(operation_symbol) + + hook = if query + SolidObjects.configuration.authorize_query + else + SolidObjects.configuration.authorize_message + end + hook.call( + actor_type:, + actor_id:, + operation: operation.to_s, + arguments:, + authorization_context: + ) + rescue UnknownActorType + false + end + # @rbs (MessageReference, Message) -> void def validate_message_reference!(message_reference, message) valid = message_reference.request_id == message.request_id && diff --git a/lib/solid_objects/configuration.rb b/lib/solid_objects/configuration.rb index 7a33ec9..5187c9d 100644 --- a/lib/solid_objects/configuration.rb +++ b/lib/solid_objects/configuration.rb @@ -31,6 +31,8 @@ class Configuration # @rbs @instance_retention_by_actor_type: Hash[String, Numeric] # @rbs @process_retention: Numeric # @rbs @prune_batch_size: Integer + # @rbs @retained_idempotency_keys: Integer + # @rbs @retained_idempotency_keys_bytes: Integer # @rbs @redrive_batch_size: Integer # @rbs @redrive_batch_pause: Float # @rbs @worker_count: Integer @@ -83,6 +85,8 @@ class Configuration :instance_retention_by_actor_type, :process_retention, :prune_batch_size, + :retained_idempotency_keys, + :retained_idempotency_keys_bytes, :redrive_batch_size, :redrive_batch_pause, :worker_count, @@ -140,6 +144,8 @@ def initialize @instance_retention_by_actor_type = {} @process_retention = 7.days @prune_batch_size = 1_000 + @retained_idempotency_keys = 64 + @retained_idempotency_keys_bytes = 16.kilobytes @redrive_batch_size = 100 @redrive_batch_pause = 0.05 @worker_count = 1 @@ -311,6 +317,8 @@ def positive_values message_retention:, process_retention:, prune_batch_size:, + retained_idempotency_keys:, + retained_idempotency_keys_bytes:, redrive_batch_size: } end diff --git a/lib/solid_objects/dead_letter_scope.rb b/lib/solid_objects/dead_letter_scope.rb index 1cb9b5f..2150c52 100644 --- a/lib/solid_objects/dead_letter_scope.rb +++ b/lib/solid_objects/dead_letter_scope.rb @@ -27,10 +27,22 @@ def self.for_kind(kind) raise ArgumentError, "unknown dead letter kind #{kind.inspect}" end - # @rbs (?authorization_context: untyped) -> ActiveRecord::Relation[untyped] + # @rbs (?authorization_context: untyped) -> Array[DeadRow] def all(authorization_context: nil) authorize!(:inspect, authorization_context:) - dead.order(updated_at: :desc, id: :desc) + dead.includes(:instance).order(updated_at: :desc, id: :desc).map do |row| + DeadRow.new( + id: row.public_send(identifier), + kind:, + actor_type: row.instance.actor_type, + actor_id: row.instance.actor_id, + status: row.status, + attempt_count: row.attempt_count, + available_at: row.available_at, + failed_at: row.updated_at, + error: row.error + ) + end end # @rbs (String, ?authorization_context: untyped) -> untyped diff --git a/lib/solid_objects/dead_row.rb b/lib/solid_objects/dead_row.rb new file mode 100644 index 0000000..4b89396 --- /dev/null +++ b/lib/solid_objects/dead_row.rb @@ -0,0 +1,15 @@ +# rbs_inline: enabled + +module SolidObjects + DeadRow = Data.define( + :id, + :kind, + :actor_type, + :actor_id, + :status, + :attempt_count, + :available_at, + :failed_at, + :error + ) +end diff --git a/lib/solid_objects/doctor.rb b/lib/solid_objects/doctor.rb index 0a26561..6864d50 100644 --- a/lib/solid_objects/doctor.rb +++ b/lib/solid_objects/doctor.rb @@ -65,11 +65,11 @@ def to_s instances: %w[ id actor_type actor_id state state_version next_message_sequence activation_owner_id activation_token activation_expires_at - activation_generation + activation_generation state_revision completed_idempotency_keys ], messages: %w[ - id instance_id delivery_mode arguments sequence attempt_count request_id - result error rejection completed_at rejected_at + id instance_id operation delivery_mode arguments sequence attempt_count + request_id result error rejection completed_at rejected_at ], ready_messages: %w[id message_id instance_id sequence available_at], claimed_messages: %w[ @@ -77,10 +77,13 @@ def to_s activation_generation claimed_at ], reminders: %w[id instance_id operation next_run_at status], - effects: %w[id message_id instance_id effect_id status available_at], + effects: %w[ + id message_id instance_id effect_id status available_at + success_operation failure_operation + ], effect_recoveries: %w[effect_id instance_id recovery_operation status_operation recovery_timeout retired_at], broadcasts: %w[id message_id instance_id broadcast_id status available_at], - dead_letters: %w[id message_id instance_id actor_type actor_id attempts], + dead_letters: %w[id message_id instance_id actor_type actor_id operation attempts], administration_events: %w[id action kind subject_id actor occurred_at], redrives: %w[id kind filters status active_scope moved move_limit started_at finished_at] }.freeze diff --git a/lib/solid_objects/errors.rb b/lib/solid_objects/errors.rb index d3f3cbe..dbad8da 100644 --- a/lib/solid_objects/errors.rb +++ b/lib/solid_objects/errors.rb @@ -263,4 +263,15 @@ class StateMigrationError < Error class ActorCallCycle < Error end + + class MessagePruned < Error + # @rbs @idempotency_key: String + attr_reader :idempotency_key + + # @rbs (String) -> void + def initialize(idempotency_key) + @idempotency_key = idempotency_key + super("the message for idempotency key #{idempotency_key.inspect} was pruned") + end + end end diff --git a/lib/solid_objects/executor.rb b/lib/solid_objects/executor.rb index f47e435..0df5724 100644 --- a/lib/solid_objects/executor.rb +++ b/lib/solid_objects/executor.rb @@ -111,7 +111,8 @@ def complete(result, observable_changes, state_after:, state_changed:) state: state_after.value, state_version: actor.class.state_version, state_revision: locked_message.sequence, - last_used_at: SolidObjects.database_adapter.database_now + last_used_at: SolidObjects.database_adapter.database_now, + completed_idempotency_keys: remembered_keys(instance, locked_message) ) locked_message.update!( result: serialized_result, @@ -394,7 +395,7 @@ def fail_message(error) error_details = serialized_error(error) dead = false - activation.lease.fenced_transaction do + activation.lease.fenced_transaction do |instance| claimed_message = matching_claim! locked_message = Message.lock.find(message.id) now = SolidObjects.database_adapter.database_now @@ -404,6 +405,7 @@ def fail_message(error) if error.is_a?(NonRetryableError) || locked_message.attempt_count >= locked_message.max_attempts create_dead_letter(message: locked_message, error_details:, now:) + instance.update!(completed_idempotency_keys: remembered_keys(instance, locked_message)) if locked_message.idempotency_key dead = true else ReadyMessage.create!( @@ -439,7 +441,10 @@ def reject_message(rejection) claimed_message = matching_claim! locked_message = Message.lock.find(message.id) now = SolidObjects.database_adapter.database_now - instance.update!(last_used_at: now) + instance.update!( + last_used_at: now, + completed_idempotency_keys: remembered_keys(instance, locked_message) + ) locked_message.update!( result: nil, rejection: rejection_data, @@ -470,6 +475,22 @@ def matching_claim! raise LostActivation, "message claim changed" end + # @rbs (Instance, Message) -> Array[Hash[String, untyped]] + def remembered_keys(instance, message) + remembered = Array(instance.completed_idempotency_keys) + key = message.idempotency_key + return remembered unless key + + entry = { "key" => key, "operation" => message.operation, "arguments" => message.arguments } + return remembered if remembered.last == entry + + kept = (remembered.reject { |value| value["key"] == key } + [ entry ]) + .last(SolidObjects.configuration.retained_idempotency_keys) + limit = SolidObjects.configuration.retained_idempotency_keys_bytes + kept.shift while kept.any? && kept.to_json.bytesize > limit + kept + end + # @rbs (Exception) -> Hash[String, untyped] def serialized_error(error) Serialization.dump( diff --git a/lib/solid_objects/message_reference.rb b/lib/solid_objects/message_reference.rb index d015cb5..afed7c1 100644 --- a/lib/solid_objects/message_reference.rb +++ b/lib/solid_objects/message_reference.rb @@ -35,16 +35,7 @@ def initialize(id:, request_id:, actor_type:, actor_id:, sequence:) # @rbs () -> String def status - Message.uncached do - message = Message.find(id) - return "rejected" if message.rejected? - return "completed" if message.completed? - return "dead" if message.dead? - return "claimed" if message.claimed? - return "ready" if message.ready? - - "unknown" - end + Message.uncached { status_of(Message.find(id)) } end # @rbs () -> untyped @@ -52,6 +43,20 @@ def result Message.uncached { Message.find(id).result } end + # @rbs () -> Outcome + def outcome + Message.uncached do + message = Message.find(id) + Outcome.new( + status: status_of(message), + result: Serialization.readonly_copy(message.result), + error: ErrorRecord.from(message.error), + rejection: RejectionRecord.from(message.rejection), + attempts: message.attempt_count + ) + end + end + # @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped def wait(timeout: 5.seconds, authorization_context: nil) SolidObjects.client.wait( @@ -60,5 +65,18 @@ def wait(timeout: 5.seconds, authorization_context: nil) authorization_context: ) end + + private + + # @rbs (Message) -> String + def status_of(message) + return "rejected" if message.rejected? + return "completed" if message.completed? + return "dead" if message.dead? + return "claimed" if message.claimed? + return "ready" if message.ready? + + "unknown" + end end end diff --git a/lib/solid_objects/outcome.rb b/lib/solid_objects/outcome.rb new file mode 100644 index 0000000..2a87e46 --- /dev/null +++ b/lib/solid_objects/outcome.rb @@ -0,0 +1,31 @@ +# rbs_inline: enabled + +module SolidObjects + ErrorRecord = Data.define(:class_name, :message, :backtrace) do + # @rbs (Hash[String, untyped]?) -> ErrorRecord? + def self.from(error) + return nil if error.blank? + + new( + class_name: error["class"], + message: error["message"], + backtrace: Serialization.readonly_copy(Array(error["backtrace"])) + ) + end + end + + RejectionRecord = Data.define(:code, :message, :details) do + # @rbs (Hash[String, untyped]?) -> RejectionRecord? + def self.from(rejection) + return nil if rejection.blank? + + new( + code: rejection["code"], + message: rejection["message"], + details: Serialization.readonly_copy(rejection["details"]) + ) + end + end + + Outcome = Data.define(:status, :result, :error, :rejection, :attempts) +end diff --git a/lib/solid_objects/reference.rb b/lib/solid_objects/reference.rb index 139e41c..5a52f57 100644 --- a/lib/solid_objects/reference.rb +++ b/lib/solid_objects/reference.rb @@ -67,6 +67,11 @@ def destroy(authorization_context: nil) SolidObjects.client.destroy(self, authorization_context:) end + # @rbs (idempotency_key: String, ?authorization_context: untyped) -> MessageReference? + def find_by(idempotency_key:, authorization_context: nil) + SolidObjects.client.find_by(reference: self, idempotency_key:, authorization_context:) + end + # @rbs (?authorization_context: untyped) -> StateSnapshot def snapshot(authorization_context: nil) SolidObjects.client.snapshot(self, authorization_context:) diff --git a/lib/solid_objects/schema_bootstrap.rb b/lib/solid_objects/schema_bootstrap.rb new file mode 100644 index 0000000..e49536e --- /dev/null +++ b/lib/solid_objects/schema_bootstrap.rb @@ -0,0 +1,27 @@ +# rbs_inline: enabled + +require "active_record" +require "active_support/core_ext/string/inflections" + +module SolidObjects + module SchemaBootstrap + class << self + # @rbs (?connection: untyped) -> void + def install(connection: nil) + migrations.each do |migration_class| + migration = migration_class.new + migration.define_singleton_method(:connection) { connection } if connection + migration.migrate(:up) + end + end + + # @rbs () -> Array[Class] + def migrations + Dir[File.expand_path("../../db/migrate/*.rb", __dir__)].sort.map do |file| + require file + Object.const_get(File.basename(file, ".rb").sub(/\A\d+_/, "").camelize) + end + end + end + end +end diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index aaaac93..d9c362b 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.15.2" + VERSION = "0.16.0" end diff --git a/sig/generated/lib/solid_objects/client.rbs b/sig/generated/lib/solid_objects/client.rbs index cfd369b..f378eb0 100644 --- a/sig/generated/lib/solid_objects/client.rbs +++ b/sig/generated/lib/solid_objects/client.rbs @@ -16,6 +16,9 @@ module SolidObjects # @rbs (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped def wait: (MessageReference, timeout: Numeric, ?authorization_context: untyped) -> untyped + # @rbs (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference? + def find_by: (?reference: Reference?, ?request_id: String?, ?idempotency_key: String?, ?authorization_context: untyped) -> MessageReference? + # @rbs (Reference, ?authorization_context: untyped) -> StateSnapshot def snapshot: (Reference, ?authorization_context: untyped) -> StateSnapshot @@ -29,6 +32,15 @@ module SolidObjects # @rbs (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference def enqueue_sync: (reference: Reference, operation: Symbol | String, arguments: Hash[Symbol | String, untyped], idempotency_key: String?, timeout: Numeric) -> MessageReference + # @rbs (Message?, authorization_context: untyped) -> MessageReference? + def readable_message: (Message?, authorization_context: untyped) -> MessageReference? + + # @rbs (Message, authorization_context: untyped) -> bool + def authorized_to_read?: (Message, authorization_context: untyped) -> bool + + # @rbs (actor_type: String, actor_id: String, operation: String, arguments: Hash[String, untyped], authorization_context: untyped) -> bool + def authorized_to_invoke?: (actor_type: String, actor_id: String, operation: String, arguments: Hash[String, untyped], authorization_context: untyped) -> bool + # @rbs (MessageReference, Message) -> void def validate_message_reference!: (MessageReference, Message) -> void diff --git a/sig/generated/lib/solid_objects/configuration.rbs b/sig/generated/lib/solid_objects/configuration.rbs index af49e26..cd1c4ca 100644 --- a/sig/generated/lib/solid_objects/configuration.rbs +++ b/sig/generated/lib/solid_objects/configuration.rbs @@ -2,69 +2,71 @@ module SolidObjects class Configuration - @table_name_prefix: String + @transmission_actor_type_resolver: Proc - @shutdown_timeout: Float + @broadcast_worker_count: Integer - @supervisor_monitor_interval: Float + @effect_worker_count: Integer - @retention_interval: Float + @worker_count: Integer - @dead_process_cleanup_interval: Float + @redrive_batch_pause: Float - @message_retention: Numeric + @redrive_batch_size: Integer - @message_retention_by_actor_type: Hash[String, Numeric] + @retained_idempotency_keys_bytes: Integer - @instance_retention_by_actor_type: Hash[String, Numeric] + @retained_idempotency_keys: Integer + + @prune_batch_size: Integer @process_retention: Numeric - @prune_batch_size: Integer + @instance_retention_by_actor_type: Hash[String, Numeric] - @redrive_batch_size: Integer + @message_retention_by_actor_type: Hash[String, Numeric] - @redrive_batch_pause: Float + @message_retention: Numeric - @worker_count: Integer + @dead_process_cleanup_interval: Float - @effect_worker_count: Integer + @retention_interval: Float - @broadcast_worker_count: Integer + @supervisor_monitor_interval: Float - @reminder_scheduler_count: Integer + @table_name_prefix: String - @connects_to: Hash[Symbol, untyped]? + @administration_identity: Proc - @logger: untyped + @authorize_transmission: Proc - @stream_signing_secret: String? + @authorize_administration: Proc - @broadcast_adapter: Proc? + @authorize_subscription: Proc - @wake_up_adapter: untyped + @authorize_destroy: Proc - @component_path_resolver: Proc? + @authorize_query: Proc - @component_authorization_context: Proc + @authorize_message: Proc @payload_authorization_context: Proc - @authorize_message: Proc + @component_authorization_context: Proc - @authorize_query: Proc + @component_path_resolver: Proc? - @authorize_destroy: Proc + @wake_up_adapter: untyped - @authorize_subscription: Proc + @broadcast_adapter: Proc? - @authorize_administration: Proc + @stream_signing_secret: String? - @authorize_transmission: Proc + @logger: untyped - @administration_identity: Proc + @connects_to: Hash[Symbol, untyped]? - @transmission_actor_type_resolver: Proc + @reminder_scheduler_count: Integer @polling_interval: Float @@ -104,6 +106,8 @@ module SolidObjects @process_alive_threshold: Float + @shutdown_timeout: Float + attr_accessor table_name_prefix: untyped attr_accessor polling_interval: untyped @@ -162,6 +166,10 @@ module SolidObjects attr_accessor prune_batch_size: untyped + attr_accessor retained_idempotency_keys: untyped + + attr_accessor retained_idempotency_keys_bytes: untyped + attr_accessor redrive_batch_size: untyped attr_accessor redrive_batch_pause: untyped diff --git a/sig/generated/lib/solid_objects/dead_letter_scope.rbs b/sig/generated/lib/solid_objects/dead_letter_scope.rbs index 3f65fd2..1110b03 100644 --- a/sig/generated/lib/solid_objects/dead_letter_scope.rbs +++ b/sig/generated/lib/solid_objects/dead_letter_scope.rbs @@ -22,8 +22,8 @@ module SolidObjects # @rbs (String) -> DeadLetterScope def self.for_kind: (String) -> DeadLetterScope - # @rbs (?authorization_context: untyped) -> ActiveRecord::Relation[untyped] - def all: (?authorization_context: untyped) -> ActiveRecord::Relation[untyped] + # @rbs (?authorization_context: untyped) -> Array[DeadRow] + def all: (?authorization_context: untyped) -> Array[DeadRow] # @rbs (String, ?authorization_context: untyped) -> untyped def retry: (String, ?authorization_context: untyped) -> untyped diff --git a/sig/generated/lib/solid_objects/dead_row.rbs b/sig/generated/lib/solid_objects/dead_row.rbs new file mode 100644 index 0000000..eaebf71 --- /dev/null +++ b/sig/generated/lib/solid_objects/dead_row.rbs @@ -0,0 +1,30 @@ +# Generated from lib/solid_objects/dead_row.rb with RBS::Inline + +module SolidObjects + class DeadRow < Data + attr_reader id(): untyped + + attr_reader kind(): untyped + + attr_reader actor_type(): untyped + + attr_reader actor_id(): untyped + + attr_reader status(): untyped + + attr_reader attempt_count(): untyped + + attr_reader available_at(): untyped + + attr_reader failed_at(): untyped + + attr_reader error(): untyped + + def self.new: (untyped id, untyped kind, untyped actor_type, untyped actor_id, untyped status, untyped attempt_count, untyped available_at, untyped failed_at, untyped error) -> instance + | (id: untyped, kind: untyped, actor_type: untyped, actor_id: untyped, status: untyped, attempt_count: untyped, available_at: untyped, failed_at: untyped, error: untyped) -> instance + + def self.members: () -> [ :id, :kind, :actor_type, :actor_id, :status, :attempt_count, :available_at, :failed_at, :error ] + + def members: () -> [ :id, :kind, :actor_type, :actor_id, :status, :attempt_count, :available_at, :failed_at, :error ] + end +end diff --git a/sig/generated/lib/solid_objects/errors.rbs b/sig/generated/lib/solid_objects/errors.rbs index b3705a3..53c74be 100644 --- a/sig/generated/lib/solid_objects/errors.rbs +++ b/sig/generated/lib/solid_objects/errors.rbs @@ -232,4 +232,12 @@ module SolidObjects class ActorCallCycle < Error end + + class MessagePruned < Error + # @rbs @idempotency_key: String + attr_reader idempotency_key: untyped + + # @rbs (String) -> void + def initialize: (String) -> void + end end diff --git a/sig/generated/lib/solid_objects/executor.rbs b/sig/generated/lib/solid_objects/executor.rbs index 98e4235..1733c1a 100644 --- a/sig/generated/lib/solid_objects/executor.rbs +++ b/sig/generated/lib/solid_objects/executor.rbs @@ -94,6 +94,9 @@ module SolidObjects # @rbs () -> ClaimedMessage def matching_claim!: () -> ClaimedMessage + # @rbs (Instance, Message) -> Array[Hash[String, untyped]] + def remembered_keys: (Instance, Message) -> Array[Hash[String, untyped]] + # @rbs (Exception) -> Hash[String, untyped] def serialized_error: (Exception) -> Hash[String, untyped] diff --git a/sig/generated/lib/solid_objects/message_reference.rbs b/sig/generated/lib/solid_objects/message_reference.rbs index 4020ed3..44390a0 100644 --- a/sig/generated/lib/solid_objects/message_reference.rbs +++ b/sig/generated/lib/solid_objects/message_reference.rbs @@ -34,7 +34,15 @@ module SolidObjects # @rbs () -> untyped def result: () -> untyped + # @rbs () -> Outcome + def outcome: () -> Outcome + # @rbs (?timeout: Numeric, ?authorization_context: untyped) -> untyped def wait: (?timeout: Numeric, ?authorization_context: untyped) -> untyped + + private + + # @rbs (Message) -> String + def status_of: (Message) -> String end end diff --git a/sig/generated/lib/solid_objects/outcome.rbs b/sig/generated/lib/solid_objects/outcome.rbs new file mode 100644 index 0000000..2b7e6c6 --- /dev/null +++ b/sig/generated/lib/solid_objects/outcome.rbs @@ -0,0 +1,52 @@ +# Generated from lib/solid_objects/outcome.rb with RBS::Inline + +module SolidObjects + class ErrorRecord < Data + attr_reader class_name(): untyped + + attr_reader message(): untyped + + attr_reader backtrace(): untyped + + def self.new: (untyped class_name, untyped message, untyped backtrace) -> instance + | (class_name: untyped, message: untyped, backtrace: untyped) -> instance + + def self.members: () -> [ :class_name, :message, :backtrace ] + + def members: () -> [ :class_name, :message, :backtrace ] + end + + class RejectionRecord < Data + attr_reader code(): untyped + + attr_reader message(): untyped + + attr_reader details(): untyped + + def self.new: (untyped code, untyped message, untyped details) -> instance + | (code: untyped, message: untyped, details: untyped) -> instance + + def self.members: () -> [ :code, :message, :details ] + + def members: () -> [ :code, :message, :details ] + end + + class Outcome < Data + attr_reader status(): untyped + + attr_reader result(): untyped + + attr_reader error(): untyped + + attr_reader rejection(): untyped + + attr_reader attempts(): untyped + + def self.new: (untyped status, untyped result, untyped error, untyped rejection, untyped attempts) -> instance + | (status: untyped, result: untyped, error: untyped, rejection: untyped, attempts: untyped) -> instance + + def self.members: () -> [ :status, :result, :error, :rejection, :attempts ] + + def members: () -> [ :status, :result, :error, :rejection, :attempts ] + end +end diff --git a/sig/generated/lib/solid_objects/reference.rbs b/sig/generated/lib/solid_objects/reference.rbs index 8f0a418..fa555e1 100644 --- a/sig/generated/lib/solid_objects/reference.rbs +++ b/sig/generated/lib/solid_objects/reference.rbs @@ -2,10 +2,10 @@ module SolidObjects class Reference - @actor_type: String - @actor_id: String + @actor_type: String + attr_reader actor_type: untyped attr_reader actor_id: untyped @@ -22,6 +22,9 @@ module SolidObjects # @rbs (?authorization_context: untyped) -> bool def destroy: (?authorization_context: untyped) -> bool + # @rbs (idempotency_key: String, ?authorization_context: untyped) -> MessageReference? + def find_by: (idempotency_key: String, ?authorization_context: untyped) -> MessageReference? + # @rbs (?authorization_context: untyped) -> StateSnapshot def snapshot: (?authorization_context: untyped) -> StateSnapshot diff --git a/sig/generated/lib/solid_objects/schema_bootstrap.rbs b/sig/generated/lib/solid_objects/schema_bootstrap.rbs new file mode 100644 index 0000000..de4b604 --- /dev/null +++ b/sig/generated/lib/solid_objects/schema_bootstrap.rbs @@ -0,0 +1,11 @@ +# Generated from lib/solid_objects/schema_bootstrap.rb with RBS::Inline + +module SolidObjects + module SchemaBootstrap + # @rbs (?connection: untyped) -> void + def self.install: (?connection: untyped) -> void + + # @rbs () -> Array[Class] + def self.migrations: () -> Array[Class] + end +end diff --git a/test/database_test_helper.rb b/test/database_test_helper.rb index 0ff0f22..66d8db5 100644 --- a/test/database_test_helper.rb +++ b/test/database_test_helper.rb @@ -21,19 +21,9 @@ ) ActiveRecord::Migration.verbose = false -require_relative "../db/migrate/20260805000000_create_solid_objects_tables" -require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" -require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns" -require_relative "../db/migrate/20260915000000_add_solid_objects_effect_recoveries" -require_relative "../db/migrate/20260922000000_add_solid_objects_administration_events" -require_relative "../db/migrate/20260922000001_add_solid_objects_redrives" - -CreateSolidObjectsTables.new.migrate(:up) -AddStateRevisionToSolidObjectsInstances.new.migrate(:up) -RenameMessageDispatchColumns.new.migrate(:up) -AddSolidObjectsEffectRecoveries.new.migrate(:up) -AddSolidObjectsAdministrationEvents.new.migrate(:up) -AddSolidObjectsRedrives.new.migrate(:up) +require "solid_objects/schema_bootstrap" + +SolidObjects::SchemaBootstrap.install ActiveRecord::Base.connection.create_table(:solid_objects_test_domain_records) do |table| table.string :name, null: false diff --git a/test/dummy/prepare_cli_reminder.rb b/test/dummy/prepare_cli_reminder.rb index 23570dd..77c594e 100644 --- a/test/dummy/prepare_cli_reminder.rb +++ b/test/dummy/prepare_cli_reminder.rb @@ -3,15 +3,9 @@ ENV["RAILS_ENV"] = "test" require_relative "config/environment" -require_relative "../../db/migrate/20260805000000_create_solid_objects_tables" -require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" -require_relative "../../db/migrate/20260813000000_rename_message_dispatch_columns" -require_relative "../../db/migrate/20260915000000_add_solid_objects_effect_recoveries" +require "solid_objects/schema_bootstrap" -CreateSolidObjectsTables.new.migrate(:up) -AddStateRevisionToSolidObjectsInstances.new.migrate(:up) -RenameMessageDispatchColumns.new.migrate(:up) -AddSolidObjectsEffectRecoveries.new.migrate(:up) +SolidObjects::SchemaBootstrap.install # A reminder that is already due, so the scheduler claims and enqueues it on # its first pass rather than waiting. diff --git a/test/dummy/prepare_cli_worker.rb b/test/dummy/prepare_cli_worker.rb index 0893b40..f7c1ef6 100644 --- a/test/dummy/prepare_cli_worker.rb +++ b/test/dummy/prepare_cli_worker.rb @@ -3,15 +3,9 @@ ENV["RAILS_ENV"] = "test" require_relative "config/environment" -require_relative "../../db/migrate/20260805000000_create_solid_objects_tables" -require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" -require_relative "../../db/migrate/20260813000000_rename_message_dispatch_columns" -require_relative "../../db/migrate/20260915000000_add_solid_objects_effect_recoveries" +require "solid_objects/schema_bootstrap" -CreateSolidObjectsTables.new.migrate(:up) -AddStateRevisionToSolidObjectsInstances.new.migrate(:up) -RenameMessageDispatchColumns.new.migrate(:up) -AddSolidObjectsEffectRecoveries.new.migrate(:up) +SolidObjects::SchemaBootstrap.install now = Time.current instance = SolidObjects::Instance.create!( diff --git a/test/dummy/web_mount_check.rb b/test/dummy/web_mount_check.rb index 9e5f258..0a71bee 100644 --- a/test/dummy/web_mount_check.rb +++ b/test/dummy/web_mount_check.rb @@ -11,16 +11,10 @@ require_relative "config/environment" require "solid_objects/web" require "rack/mock_request" -require_relative "../../db/migrate/20260805000000_create_solid_objects_tables" -require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances" -require_relative "../../db/migrate/20260813000000_rename_message_dispatch_columns" -require_relative "../../db/migrate/20260915000000_add_solid_objects_effect_recoveries" +require "solid_objects/schema_bootstrap" ActiveRecord::Migration.verbose = false -CreateSolidObjectsTables.new.migrate(:up) -AddStateRevisionToSolidObjectsInstances.new.migrate(:up) -RenameMessageDispatchColumns.new.migrate(:up) -AddSolidObjectsEffectRecoveries.new.migrate(:up) +SolidObjects::SchemaBootstrap.install instance = SolidObjects::Instance.create!( actor_type: "MountCheckActor", diff --git a/test/integration/dead_letter_scopes_test.rb b/test/integration/dead_letter_scopes_test.rb index 2169879..42b3c8b 100644 --- a/test/integration/dead_letter_scopes_test.rb +++ b/test/integration/dead_letter_scopes_test.rb @@ -144,11 +144,26 @@ def run broadcasts = SolidObjects.dead_letters.broadcasts.all(authorization_context: "operator") messages = SolidObjects.dead_letters.all(authorization_context: "operator") - assert_equal [ effect.effect_id ], effects.map(&:effect_id) - assert_equal [ broadcast.broadcast_id ], broadcasts.map(&:broadcast_id) + assert_equal [ effect.effect_id ], effects.map(&:id) + assert_equal [ broadcast.broadcast_id ], broadcasts.map(&:id) assert_equal 1, messages.count end + test "lists a dead row whose id retry accepts" do + effect = dead_effect + + row = SolidObjects.dead_letters.effects.all(authorization_context: "operator").sole + + assert_equal effect.effect_id, row.id + assert_equal "effect", row.kind + assert_equal "dead", row.status + assert_equal "scoped-dead-letter-orders", row.actor_type + assert_equal "one", row.actor_id + SolidObjects.dead_letters.effects.retry(row.id, authorization_context: "operator") + + assert_equal "pending", effect.reload.status + end + test "reads only dead rows, not pending ones" do dead = dead_effect OrderActor.ref("two").async.place @@ -157,7 +172,7 @@ def run effects = SolidObjects.dead_letters.effects.all(authorization_context: "operator") assert_equal 2, SolidObjects::Effect.count - assert_equal [ dead.effect_id ], effects.map(&:effect_id) + assert_equal [ dead.effect_id ], effects.map(&:id) end test "refuses an unauthorized caller" do diff --git a/test/integration/doctor_test.rb b/test/integration/doctor_test.rb index b669c58..687a25e 100644 --- a/test/integration/doctor_test.rb +++ b/test/integration/doctor_test.rb @@ -3,6 +3,12 @@ require "database_test_helper" require "rake" require "solid_objects/doctor" +require "solid_objects/schema_bootstrap" +require "tmpdir" + +class DoctorScratchSchema < ActiveRecord::Base + self.abstract_class = true +end class DoctorTest < ActiveSupport::TestCase test "verifies a workerless synchronous installation" do @@ -113,6 +119,37 @@ class DoctorTest < ActiveSupport::TestCase assert_equal :skip, report.check(:sync_round_trip).status end + test "fails when a migration that a runtime path needs is missing" do + installed = SolidObjects::Record.connection + instances = SolidObjects.table_name(:instances) + connection = Object.new + connection.define_singleton_method(:data_sources) { installed.data_sources } + connection.define_singleton_method(:columns) do |table| + columns = installed.columns(table) + next columns unless table == instances + + columns.reject { |column| column.name == "completed_idempotency_keys" } + end + + report = SolidObjects::Doctor.new(connection:).call + + refute report.healthy? + assert_equal :fail, report.check(:schema).status + assert_match(/completed_idempotency_keys/, report.check(:schema).message) + end + + test "names a column from every migration that follows the first" do + added = columns_later_migrations_add + + refute_empty added, "the schema has no migration after the first to verify" + unnamed = added.flat_map do |table, columns| + listed = SolidObjects::Doctor::EXPECTED_COLUMNS.fetch(table, []) + (columns - listed).map { |column| "#{table}.#{column}" } + end + + assert_empty unnamed, "the doctor cannot report these half-applied migrations" + end + test "reports live runtime roles" do now = SolidObjects.database_adapter.database_now SolidObjects::Process.create!( @@ -147,6 +184,41 @@ class DoctorTest < ActiveSupport::TestCase private + # @rbs () -> Hash[Symbol, Array[String]] + def columns_later_migrations_add + Dir.mktmpdir do |directory| + migrations = SolidObjects::SchemaBootstrap.migrations + first = schema_columns(directory, "first", migrations.first(1)) + whole = schema_columns(directory, "whole", migrations) + first.each_with_object({}) do |(table, columns), added| + later = whole.fetch(table) - columns + added[table] = later unless later.empty? + end + end + end + + # @rbs (String, String, Array[Class]) -> Hash[Symbol, Array[String]] + def schema_columns(directory, name, migrations) + DoctorScratchSchema.establish_connection( + adapter: "sqlite3", + database: File.join(directory, "#{name}.sqlite3") + ) + connection = DoctorScratchSchema.connection + migrations.each do |migration_class| + migration = migration_class.new + migration.define_singleton_method(:connection) { connection } + migration.migrate(:up) + end + SolidObjects::Doctor::EXPECTED_COLUMNS.keys.each_with_object({}) do |table, columns| + name = SolidObjects.table_name(table) + next unless connection.data_sources.include?(name) + + columns[table] = connection.columns(name).map(&:name) + end + ensure + DoctorScratchSchema.remove_connection + end + def hold_sqlite_write_lock locked = Queue.new release = Queue.new diff --git a/test/integration/load_contract_test.rb b/test/integration/load_contract_test.rb index 680455c..9e413d7 100644 --- a/test/integration/load_contract_test.rb +++ b/test/integration/load_contract_test.rb @@ -19,6 +19,7 @@ class LoadContractTest < ActiveSupport::TestCase "client" => "the caller path, required by SolidObjects.client", "doctor" => "an operator tool, loaded by the doctor command", "errors" => "defines error classes individually, so no SolidObjects::Errors exists", + "schema_bootstrap" => "a setup helper, required by a script that builds the schema", "sync_diagnostics" => "the caller path, required with the client", "synchronous_invocation" => "the caller path, required with the client", "test_helper" => "opt-in, required by host application tests", diff --git a/test/integration/result_lookup_test.rb b/test/integration/result_lookup_test.rb new file mode 100644 index 0000000..2477e47 --- /dev/null +++ b/test/integration/result_lookup_test.rb @@ -0,0 +1,440 @@ +# frozen_string_literal: true + +require "database_test_helper" + +class ResultLookupTest < ActiveSupport::TestCase + class CartActor < SolidObjects::Actor + actor_type "lookup-carts" + + attribute :items, default: 0 + + class << self + attr_accessor :fail + end + + def checkout(order_id:) + raise "payment declined" if self.class.fail + + self.items += 1 + { "order_id" => order_id } + end + + def reject_checkout + reject("closed", "the cart is closed") + end + + query :total do + items + end + end + + setup do + SolidObjects.configuration.retry_delay = ->(_attempt) { 0 } + SolidObjects.configuration.max_attempts = 1 + CartActor.fail = false + end + + test "finds a completed message by request id and reads its result" do + CartActor.ref("alice").sync.checkout(order_id: 4210) + original = SolidObjects::Message.sole + + found = SolidObjects.client.find_by( + request_id: original.request_id, + authorization_context: "operator" + ) + + assert_equal original.id, found.id + assert_equal "completed", found.status + assert_equal({ "order_id" => 4210 }, found.result) + end + + test "reports no result for a message that was enqueued asynchronously" do + original = CartActor.ref("alice").async.checkout(order_id: 4210) + run_actors + + found = SolidObjects.client.find_by(request_id: original.request_id) + + assert_equal "completed", found.status + assert_nil found.result + end + + test "finds a completed message by idempotency key on its reference" do + reference = CartActor.ref("alice") + reference.sync(idempotency_key: "checkout-7f3a").checkout(order_id: 4210) + original = SolidObjects::Message.sole + + found = reference.find_by(idempotency_key: "checkout-7f3a", authorization_context: "operator") + + assert_equal original.id, found.id + assert_equal({ "order_id" => 4210 }, found.result) + end + + test "the client and the reference find the same message" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + + through_reference = reference.find_by(idempotency_key: "checkout-7f3a") + through_client = SolidObjects.client.find_by( + reference: reference, + idempotency_key: "checkout-7f3a" + ) + + assert_equal through_reference.id, through_client.id + end + + test "finds a message that has not run yet" do + original = CartActor.ref("alice").async.checkout(order_id: 1) + + found = SolidObjects.client.find_by(request_id: original.request_id) + + assert_equal "ready", found.status + end + + test "finds a dead message and reports its error and attempts" do + CartActor.fail = true + original = CartActor.ref("alice").async.checkout(order_id: 1) + run_actors + + found = SolidObjects.client.find_by(request_id: original.request_id) + outcome = found.outcome + + assert_equal "dead", found.status + assert_equal "dead", outcome.status + assert_equal 1, outcome.attempts + assert_equal "RuntimeError", outcome.error.class_name + assert_equal "payment declined", outcome.error.message + assert_nil outcome.result + end + + test "finds a rejected message and reports its rejection" do + original = CartActor.ref("alice").async.reject_checkout + run_actors + + found = SolidObjects.client.find_by(request_id: original.request_id) + outcome = found.outcome + + assert_equal "rejected", found.status + assert_equal "rejected", outcome.status + assert_equal "closed", outcome.rejection.code + assert_equal "the cart is closed", outcome.rejection.message + end + + test "reports a completed outcome with its result" do + CartActor.ref("alice").sync.checkout(order_id: 9) + original = SolidObjects::Message.sole + + outcome = SolidObjects.client.find_by(request_id: original.request_id).outcome + + assert_equal "completed", outcome.status + assert_equal({ "order_id" => 9 }, outcome.result) + assert_nil outcome.error + assert_nil outcome.rejection + end + + test "returns nil for an unknown request id and an unknown key" do + reference = CartActor.ref("alice") + reference.async.checkout(order_id: 1) + + assert_nil SolidObjects.client.find_by(request_id: SecureRandom.uuid) + assert_nil reference.find_by(idempotency_key: "never-used") + end + + test "refuses a lookup that names no key" do + error = assert_raises(ArgumentError) { SolidObjects.client.find_by } + + assert_match(/exactly one of/, error.message) + end + + test "refuses a lookup that names both keys" do + error = assert_raises(ArgumentError) do + SolidObjects.client.find_by(request_id: "one", idempotency_key: "two") + end + + assert_match(/exactly one of/, error.message) + end + + test "refuses an idempotency key without a reference" do + error = assert_raises(ArgumentError) do + SolidObjects.client.find_by(idempotency_key: "checkout-7f3a") + end + + assert_match(/requires reference/, error.message) + end + + test "refuses an unknown keyword" do + assert_raises(ArgumentError) { SolidObjects.client.find_by(bogus: "one") } + end + + test "returns nil to a caller that cannot read the message" do + original = CartActor.ref("alice").async.checkout(order_id: 1) + SolidObjects.configuration.authorize_message = ->(**) { false } + + assert_nil SolidObjects.client.find_by( + request_id: original.request_id, + authorization_context: "stranger" + ) + assert_nil CartActor.ref("alice").find_by(idempotency_key: "never-used") + end + + test "answers nil for a message whose actor type is not registered" do + original = CartActor.ref("alice").async.checkout(order_id: 1) + SolidObjects::Message.update_all(actor_type: "retired-carts") + + assert_nil SolidObjects.client.find_by(request_id: original.request_id) + end + + test "reports one snapshot for every outcome field" do + CartActor.ref("alice").sync.checkout(order_id: 9) + found = SolidObjects.client.find_by(request_id: SolidObjects::Message.sole.request_id) + reads = 0 + subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |*, payload| + reads += 1 if payload[:sql].include?(SolidObjects::Message.table_name) + end + + found.outcome + + assert_equal 1, reads, "every outcome field must describe one read" + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + test "hands out a frozen result" do + CartActor.ref("alice").sync.checkout(order_id: 9) + outcome = SolidObjects.client.find_by(request_id: SolidObjects::Message.sole.request_id).outcome + + assert_predicate outcome.result, :frozen? + assert_raises(FrozenError) { outcome.result["order_id"] = 1 } + end + + test "hands out a frozen backtrace" do + CartActor.fail = true + original = CartActor.ref("alice").async.checkout(order_id: 1) + run_actors + outcome = SolidObjects.client.find_by(request_id: original.request_id).outcome + + assert_predicate outcome.error.backtrace.first, :frozen? + end + + test "propagates an authorization failure rather than reporting absence" do + original = CartActor.ref("alice").async.checkout(order_id: 1) + SolidObjects.configuration.authorize_message = ->(**) { raise "authorization service is down" } + + error = assert_raises(RuntimeError) do + SolidObjects.client.find_by(request_id: original.request_id) + end + + assert_equal "authorization service is down", error.message + end + + test "authorizes against the stored operation and arguments" do + reference = CartActor.ref("alice") + original = reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 4210) + seen = [] + SolidObjects.configuration.authorize_message = lambda do |operation:, arguments:, **| + seen << [ operation, arguments ] + true + end + + SolidObjects.client.find_by(request_id: original.request_id) + + assert_equal [ [ "checkout", { "order_id" => 4210 } ] ], seen + end + + test "uses the query hook for a query message" do + CartActor.ref("alice").sync.total + original = SolidObjects::Message.sole + hooks = [] + SolidObjects.configuration.authorize_query = ->(**) { hooks << :query and true } + SolidObjects.configuration.authorize_message = ->(**) { hooks << :message and true } + + SolidObjects.client.find_by(request_id: original.request_id) + + assert_equal [ :query ], hooks + end + + test "does not find a key that belongs to another instance" do + CartActor.ref("alice").async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + + assert_nil CartActor.ref("bob").find_by(idempotency_key: "checkout-7f3a") + end + + test "rebuilds a reference that can wait for its result" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 4210) + found = reference.find_by(idempotency_key: "checkout-7f3a") + + run_actors + found.wait(timeout: 2.0) + + assert_equal "completed", found.status + assert_equal 1, CartActor.ref("alice").snapshot.items + end + + test "tells a pruned message from one that never existed" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + + error = assert_raises(SolidObjects::MessagePruned) do + reference.find_by(idempotency_key: "checkout-7f3a") + end + + assert_equal "checkout-7f3a", error.idempotency_key + assert_nil reference.find_by(idempotency_key: "never-used") + end + + test "does not tell a refused caller that a key was pruned" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + SolidObjects.configuration.authorize_message = ->(**) { false } + SolidObjects.configuration.authorize_query = ->(**) { false } + + assert_nil reference.find_by( + idempotency_key: "checkout-7f3a", + authorization_context: "stranger" + ) + end + + test "does not tell a snapshot-only caller that a key was pruned" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "checkout-7f3a").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + SolidObjects.configuration.authorize_query = ->(**) { true } + SolidObjects.configuration.authorize_message = ->(**) { false } + + assert_nil reference.find_by(idempotency_key: "checkout-7f3a") + end + + test "authorizes pruned keys with the original arguments" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "protected").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + seen = [] + SolidObjects.configuration.authorize_message = lambda do |arguments:, **| + seen << arguments + arguments["order_id"] != 1 + end + + assert_nil reference.find_by(idempotency_key: "protected") + assert_equal [ { "order_id" => 1 } ], seen + SolidObjects.configuration.authorize_message = ->(arguments:, **) { arguments["order_id"] == 1 } + assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "protected") } + end + + test "does not disclose legacy keys without authorization arguments" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "legacy").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + SolidObjects::Instance.sole.update!(completed_idempotency_keys: [ { "key" => "legacy", "operation" => "checkout" } ]) + + assert_nil reference.find_by(idempotency_key: "legacy") + end + + test "remembers a key whose message was rejected" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "rejected-7f3a").reject_checkout + run_actors + SolidObjects::Message.delete_all + + assert_raises(SolidObjects::MessagePruned) do + reference.find_by(idempotency_key: "rejected-7f3a") + end + end + + test "remembers a key whose message died" do + CartActor.fail = true + reference = CartActor.ref("alice") + reference.async(idempotency_key: "dead-7f3a").checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + SolidObjects::DeadLetter.delete_all + + assert_raises(SolidObjects::MessagePruned) do + reference.find_by(idempotency_key: "dead-7f3a") + end + end + + test "bounds what an instance remembers" do + SolidObjects.configuration.retained_idempotency_keys = 3 + reference = CartActor.ref("alice") + 5.times { |index| reference.async(idempotency_key: "key-#{index}").checkout(order_id: index) } + run_actors + SolidObjects::Message.delete_all + + assert_nil reference.find_by(idempotency_key: "key-0") + assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "key-4") } + assert_equal 3, SolidObjects::Instance.sole.completed_idempotency_keys.size + end + + test "remembers every key of one activation pass" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "first").checkout(order_id: 1) + reference.async(idempotency_key: "second").checkout(order_id: 2) + run_actors + SolidObjects::Message.delete_all + + assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "first") } + assert_raises(SolidObjects::MessagePruned) { reference.find_by(idempotency_key: "second") } + end + + test "bounds what an instance remembers by size" do + SolidObjects.configuration.retained_idempotency_keys_bytes = 128 + reference = CartActor.ref("alice") + keys = 3.times.map { |index| "#{index}-#{"k" * 20}" } + keys.each_with_index { |key, index| reference.async(idempotency_key: key).checkout(order_id: index) } + run_actors + + remembered = SolidObjects::Instance.sole.completed_idempotency_keys + + assert_equal keys.last(1), remembered.map { |entry| entry["key"] } + assert_operator remembered.to_json.bytesize, :<=, 128 + end + + test "remembers nothing for a key larger than what it retains" do + SolidObjects.configuration.retained_idempotency_keys_bytes = 16 + reference = CartActor.ref("alice") + key = "k" * 100 + reference.async(idempotency_key: key).checkout(order_id: 1) + run_actors + SolidObjects::Message.delete_all + + assert_empty SolidObjects::Instance.sole.completed_idempotency_keys + assert_nil reference.find_by(idempotency_key: key) + end + + test "remembers a re-sent key once" do + reference = CartActor.ref("alice") + reference.async(idempotency_key: "first").checkout(order_id: 1) + reference.async(idempotency_key: "second").checkout(order_id: 2) + run_actors + SolidObjects::Message.delete_all + reference.async(idempotency_key: "first").checkout(order_id: 3) + run_actors + + assert_equal [ "second", "first" ], + SolidObjects::Instance.sole.completed_idempotency_keys.map { |entry| entry["key"] } + end + + test "remembers nothing for a message that carried no key" do + reference = CartActor.ref("alice") + reference.async.checkout(order_id: 1) + run_actors + + assert_empty SolidObjects::Instance.sole.completed_idempotency_keys + end + + private + + def run_actors + worker = SolidObjects::Worker.new + worker.run_until_idle + ensure + worker&.stop + end +end diff --git a/test/integration/separate_database_test.rb b/test/integration/separate_database_test.rb index 16cf46c..af5b6b3 100644 --- a/test/integration/separate_database_test.rb +++ b/test/integration/separate_database_test.rb @@ -52,16 +52,7 @@ def connect_solid_objects_to(database) pool: 10, timeout: 5_000 ) - [ - CreateSolidObjectsTables, - AddStateRevisionToSolidObjectsInstances, - RenameMessageDispatchColumns, - AddSolidObjectsEffectRecoveries - ].each do |migration_class| - migration = migration_class.new - migration.define_singleton_method(:connection) { SolidObjects::Record.connection } - migration.migrate(:up) - end + SolidObjects::SchemaBootstrap.install(connection: SolidObjects::Record.connection) SolidObjects.reset! authorize_all_actor_operations SolidObjects::Record.descendants.each(&:reset_column_information) diff --git a/test/integration/wake_up_selection_test.rb b/test/integration/wake_up_selection_test.rb index a3af384..656a53b 100644 --- a/test/integration/wake_up_selection_test.rb +++ b/test/integration/wake_up_selection_test.rb @@ -260,6 +260,17 @@ def wait(timeout:) = false end end + test "a load error is not reported as an unreachable database" do + SolidObjects::WakeUpAdapters.singleton_class.alias_method(:built, :build) + SolidObjects::WakeUpAdapters.define_singleton_method(:build) do |_name| + raise NameError, "uninitialized constant SolidObjects::Record" + end + + assert_raises(NameError) { SolidObjects.wake_up } + ensure + SolidObjects::WakeUpAdapters.singleton_class.alias_method(:build, :built) + end + test "the doctor reports the selected adapter" do SolidObjects.configuration.authorize_administration = ->(**) { true } check = SolidObjects::Doctor.new.call.check(:wake_up) diff --git a/test/unit/migration_bootstrap_test.rb b/test/unit/migration_bootstrap_test.rb new file mode 100644 index 0000000..c55996a --- /dev/null +++ b/test/unit/migration_bootstrap_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "test_helper" + +class MigrationBootstrapTest < ActiveSupport::TestCase + ROOT = File.expand_path("../..", __dir__) + + test "no script names a migration class from a hand-copied list" do + offenders = ruby_files.select do |file| + body = File.read(file) + migration_classes.any? { |name| body.include?(name) } + end + + assert_empty offenders.map { |file| file.delete_prefix("#{ROOT}/") }, + "apply migrations through SolidObjects::SchemaBootstrap so the list cannot drift" + end + + private + + def migration_classes + Dir[File.join(ROOT, "db/migrate/*.rb")].map do |file| + File.basename(file, ".rb").sub(/\A\d+_/, "").camelize + end + end + + def ruby_files + Dir[File.join(ROOT, "{lib,test,examples,benchmark}/**/*.rb")] - [ + File.join(ROOT, "test/unit/migration_bootstrap_test.rb") + ] + end +end