diff --git a/README.md b/README.md index d192c61..0ff6a28 100644 --- a/README.md +++ b/README.md @@ -219,9 +219,10 @@ Views can always be recomputed if the original events are still decodable. That is why event codec compatibility matters. The pure `factos` package keeps this storage-independent contract. The -PostgreSQL backend `factos_pog` can additionally maintain an application-owned -projection and its named cursor in one transaction, then make a dispatch wait -for that cursor before the caller reads the projection. +PostgreSQL backend `factos_pog` can attach a strong subscription callback to a +dispatch. Projection rows written through its transaction connection commit or +roll back with the originating event and are query-visible when dispatch +returns successfully. ## How are effects handled? diff --git a/backends/factos_pog/README.md b/backends/factos_pog/README.md index f585569..ade2350 100644 --- a/backends/factos_pog/README.md +++ b/backends/factos_pog/README.md @@ -13,10 +13,10 @@ are expressed with Factos event types and tags. ## Guides - [How it works](how-it-works) — transaction and storage overview. -- [Durable subscriptions](durable-subscriptions) — ordered catch-up with - `LISTEN`/`NOTIFY` wake-ups. -- [Durable effects](durable-effects) — running application-owned effects with - at-least-once delivery. +- [Subscriptions](subscriptions) — dispatch-bound transactional and + fire-and-forget callbacks. +- [Durable effects](durable-effects) — application-owned transactional outbox + delivery. ## Install @@ -51,17 +51,13 @@ The current schema contains: - `factos_events`: append-only event rows with JSONB metadata and data; - `factos_event_tags`: indexed tag rows used for context reads; -- `factos_subscriptions`: private durable cursors for managed consumers; -- an append-order lock and an `AFTER INSERT` trigger that publishes committed - event envelopes on the private `factos_pog_events` channel. - -The event log is authoritative. Notifications carry the normal delivery path; -cursor-based reads recover anything not delivered by the notification session. +- a transaction-scoped append lock that keeps global event positions ordered by + commit. The dbmate history retains the `1.0.0` event-store baseline and one `2.0.0` upgrade. `20260816000100_factos_pog_v2.sql` converts stored JSON to JSONB, -enforces UUIDv4 event identity, and installs generation-checked durable -subscriptions with complete recorded event notification envelopes. +enforces UUIDv4 event identity, and orders event positions by commit. The +backend stores no subscription cursors or checkpoints. ## Define a codec @@ -143,11 +139,11 @@ The backend: 4. runs the decider; 5. appends only if the observed context is still current; 6. inserts event and tag-index rows; -7. retries serialization/deadlock failures up to the builder's retry attempts; -8. commits, after which PostgreSQL exposes the notification. +7. runs matching strong subscription callbacks with the transaction connection; +8. retries serialization/deadlock failures up to the builder's retry attempts; +9. commits, then starts matching fire-and-forget subscription work. `dispatch.events` contains the committed records inserted by this dispatch. -External work does not run inside the transaction. Leave the query unset when one stream revision is intentionally the consistency boundary: @@ -164,10 +160,41 @@ let assert Ok(dispatch) = ``` `with_query` controls the facts that must remain stable while the command -transaction commits. It does not wait for a read model. When the caller will -immediately query named PostgreSQL projections, use the post-commit barrier: +transaction commits. It does not wait for a separately maintained read model. + +## Subscribe to a dispatch + +A subscription filters the decoded events produced by one dispatch and handles +each matching `factos.Recorded` envelope. Attach subscriptions to the dispatch +builder: ```gleam +let subscription_supervisor = factos_pog.new_subscription_supervisor() + +let subscriptions = [ + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.StrongConsistency, + handle: fn(transaction_connection, recorded) { + ticket_projection.apply(transaction_connection, recorded) + }, + ), + factos_pog.new_subscription( + query: factos.query([ + factos.query_item( + types: [factos.event_type("TicketSold")], + tags: [], + ), + ]), + consistency: factos_pog.FireAndForget( + supervisor: subscription_supervisor, + ), + handle: fn(connection, recorded) { + ticket_observer.handle(connection, recorded) + }, + ), +] + let assert Ok(dispatch) = factos_pog.new_dispatch( connection:, @@ -175,115 +202,69 @@ let assert Ok(dispatch) = decider: ticket_decider(), codec: ticket_codec(), ) - |> factos_pog.dispatch_and_wait( + |> factos_pog.with_subscriptions(subscriptions:) + |> factos_pog.dispatch( BuyTicket(buyer: "renata"), event_id: uuid.v4_string, - subscriptions: ["ticket-projection"], - timeout: duration.seconds(5), ) ``` -Name only projections the caller intends to query. A post-commit timeout or -missing name returns `DispatchCommittedButNotObserved` with the committed -`Dispatch`; it does not roll the event back. - -## Consume committed events durably +All subscriptions in one list share a callback error type. -A managed subscription owns its PostgreSQL `LISTEN` session and a private -durable cursor. Supply a stable name, an event query and codec, and one handler: - -```gleam -let assert Ok(subscription) = - factos_pog.new_subscription( - connection:, - config:, - name: "ticket-projection", - query: factos.AllEvents, - start_from: factos_pog.Origin, - codec: ticket_codec(), - handle: fn(recorded) { - ticket_projection.apply(recorded) - }, - ) -``` +### Strong consistency -The handler receives a complete `factos.Recorded` envelope, including event id, -stream, revision, global position, descriptor, and domain event. The backend -stores the cursor in `factos_subscriptions`, processes events sequentially, and -checkpoints each event after the handler returns `Ok(Nil)`. +`StrongConsistency` callbacks run in subscription-list order and event append +order inside the dispatch's serializable transaction. The supplied connection +is the transaction-scoped connection. This makes a PostgreSQL projection row +and its originating event one atomic commit. -For an application-owned PostgreSQL projection, commit the projection row and -cursor atomically: +The first callback `Error` becomes +`SubscriptionError(error: callback_error)` and rolls back the accepted events, +tag rows, that callback's writes, and every earlier strong callback write. +Callback failures are not retried. -```gleam -let assert Ok(subscription) = - factos_pog.new_projection_subscription( - connection:, - config:, - name: "ticket-projection", - query: factos.AllEvents, - start_from: factos_pog.Origin, - codec: ticket_codec(), - project: fn(transaction_connection, recorded) { - ticket_projection.apply(transaction_connection, recorded) - }, - ) -``` +A PostgreSQL serialization or deadlock failure still retries the complete +dispatch transaction, including any strong callbacks already run by the +aborted attempt. Keep their side effects on the supplied transaction connection +so rollback and retry remain safe. -`Origin`, `Current`, and `After(position:)` initialize only a new stable name. -Use `subscription_status` to inspect its cursor, generation, global log head, -and exact global-log lag. Use `reset_subscription` to reposition an existing -name and increment its generation; an online worker reloads immediately. +### Fire and forget -For development or embedding outside an application supervisor: +Allocate one subscription supervisor at application startup and install it in +the application's supervision tree: ```gleam -let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - -let assert Ok(Nil) = factos_pog.stop(handle) -``` +let subscription_supervisor = factos_pog.new_subscription_supervisor() -In an application tree, install the permanent child specification: - -```gleam static_supervisor.new(strategy: static_supervisor.OneForOne) |> static_supervisor.add( - factos_pog.supervised(subscription), + factos_pog.supervised_subscription_supervisor(subscription_supervisor), ) |> static_supervisor.start ``` -The managed subscription establishes `LISTEN` before loading its cursor. A -normal notification carries the committed event cursor, descriptor, and JSON -data, so a contiguous matching event is decoded and handled without another -event-row query. A cursor gap, malformed payload, or oversized event falls back -to an ordered catch-up read. The worker also reconciles every 30 seconds so a -commit missed while Pog reconnects cannot remain undiscovered forever. - -The event log and cursor provide durability; notifications provide the fast -path. A processing failure restarts the subscriber from its last successful -event. A notification-session failure restarts the session and subscriber in -dependency order. - -Ordinary handlers are at least once. A crash after an external effect succeeds -but before the separate cursor update can replay the event. Use `Recorded.id` -when one source event maps to one operation, or a stable domain-operation key -when one operation spans events. Atomic PostgreSQL projection callbacks instead -commit their changes and cursor together. Retry schedules, dead letters, and -poison-event policy remain application concerns. - -Only one running worker should own a subscription name. Use leader election or -a session advisory lock when multiple application instances may start the same -consumer. - -A handler that dispatches a follow-up command must not wait for its own -subscription name: that cursor cannot advance until the handler returns, so the -follow-up commits and then times out. - -`listen`, `unlisten`, and `read_after` remain available as low-level -primitives for applications that need a custom scheduler, batch model, or -checkpoint protocol. +Retain the same opaque `subscription_supervisor` value when constructing +`FireAndForget(supervisor:)` subscriptions. The backend starts one supervised +temporary child for each matching fire-and-forget subscription only after the +dispatch commits. That child handles matching records in append order with the +builder's ordinary Pog connection. + +Fire-and-forget callbacks do not delay or alter the committed dispatch result. +A returned `Error` is ignored and processing continues with the next record. A +panic terminates that temporary child and drops its remaining records. Separate +subscriptions and dispatches may run concurrently. + +This mode has no cursor, catch-up, checkpoint, replay, or retry. If the +supervisor is unavailable, work is dropped. Use it only for best-effort work. +Durable external delivery requires an application-owned transactional outbox; +see [Durable effects](durable-effects). + +### Custom consumer runtimes + +`read_after` remains available for applications that need their own scheduler, +batching, checkpoint, and recovery protocol. Poll ordered reads from the event +log, persist an application-owned checkpoint, and treat those records as the +durable source of truth. ## Run tests @@ -305,6 +286,7 @@ The defaults match `compose.yml`. Override a remote or CI service with PostgreSQL `SERIALIZABLE` isolation protects arbitrary event-type/tag predicates without a global application lock. Conflicting transactions can abort and retry, -so deciders, event codecs, and event-id generators must remain pure. External -work starts only from committed events after dispatch returns or a durable -subscriber catches them up. +so deciders, event codecs, and event-id generators must remain pure. Strong +subscription callbacks may rerun after an aborted attempt and must keep side +effects inside the supplied transaction. Fire-and-forget work starts only +after a successful final commit. diff --git a/backends/factos_pog/dev/factos_pog_dev.gleam b/backends/factos_pog/dev/factos_pog_dev.gleam index 49d05cd..40fd4ce 100644 --- a/backends/factos_pog/dev/factos_pog_dev.gleam +++ b/backends/factos_pog/dev/factos_pog_dev.gleam @@ -2,6 +2,7 @@ import factos import factos/factos_pog import gleam/dynamic/decode import gleam/erlang/application +import gleam/erlang/atom import gleam/erlang/process import gleam/int import gleam/io @@ -9,9 +10,9 @@ import gleam/json import gleam/list import gleam/option.{Some} import gleam/otp/actor +import gleam/otp/static_supervisor import gleam/result import gleam/string -import gleam/time/duration import gleamy/bench import pog import simplifile @@ -40,13 +41,8 @@ fn run() -> Result(Nil, testcontainer_error.Error) { use postgres_container <- testcontainer.with_formula( postgres.new() |> postgres.formula(), ) - let #(pool_pid, config, connection) = start_connection(postgres_container) - let listener_config = - pog.Config( - ..config, - pool_name: process.new_name(prefix: "factos_pog_dev_event_listener"), - ) - let input = BenchmarkInput(connection: connection) + let #(pool_pid, connection) = start_connection(postgres_container) + let input = BenchmarkInput(connection:) bench.run( [bench.Input("postgres", input)], @@ -59,8 +55,8 @@ fn run() -> Result(Nil, testcontainer_error.Error) { |> bench.table([bench.IPS, bench.Min, bench.Mean, bench.P(99)]) |> io.println - smoke_subscription(listener_config, connection) - io.println("durable subscription smoke: caught up") + smoke_subscription(connection) + io.println("subscription smoke: strong committed; fire observed") process.send_exit(pool_pid) process.sleep(100) @@ -84,7 +80,7 @@ type State { } type WorkerMessage { - WorkerDone(worker: Int, result: Result(Nil, factos_pog.Error(Nil))) + WorkerDone(worker: Int, result: Result(Nil, factos_pog.Error(Nil, Nil))) } fn setup_sequential(input: BenchmarkInput) -> fn(BenchmarkInput) -> Nil { @@ -103,7 +99,7 @@ fn setup_concurrent(input: BenchmarkInput) -> fn(BenchmarkInput) -> Nil { fn start_connection( postgres_container: postgres.PostgresContainer, -) -> #(process.Pid, pog.Config, pog.Connection) { +) -> #(process.Pid, pog.Connection) { let postgres.PostgresContainer(host:, port:, database:, username:, ..) = postgres_container let pool_name = process.new_name("factos_pog_dev") @@ -118,7 +114,7 @@ fn start_connection( let assert Ok(actor.Started(pid:, ..)) = pog.start(config) process.sleep(100) - #(pid, config, pog.named_connection(pool_name)) + #(pid, pog.named_connection(pool_name)) } fn reset_schema(connection: pog.Connection) -> Nil { @@ -131,9 +127,6 @@ fn reset_schema(connection: pog.Connection) -> Nil { let assert Ok(_) = pog.query("drop function if exists factos_pog_notify_outbox_available()") |> pog.execute(on: connection) - let assert Ok(_) = - pog.query("drop table if exists factos_subscriptions") - |> pog.execute(on: connection) let assert Ok(_) = pog.query("drop table if exists factos_event_tags") |> pog.execute(on: connection) @@ -261,7 +254,9 @@ fn wait_for_workers( "benchmark worker " <> int.to_string(worker) <> " failed: " - <> factos_pog.error_to_string(error, fn(_) { "nil" }), + <> factos_pog.error_to_string(error, fn(_) { "nil" }, fn(_) { + "nil" + }), ) panic as "benchmark worker failed" } @@ -274,7 +269,7 @@ fn run_worker( connection: pog.Connection, worker: Int, remaining: Int, -) -> Result(Nil, factos_pog.Error(Nil)) { +) -> Result(Nil, factos_pog.Error(Nil, Nil)) { case remaining <= 0 { True -> Ok(Nil) False -> { @@ -290,7 +285,7 @@ fn run_worker( fn dispatch_once( connection: pog.Connection, stream_name: String, -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Nil)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Nil, Nil)) { factos_pog.new_dispatch( connection: connection, stream: stream_name, @@ -301,7 +296,7 @@ fn dispatch_once( |> factos_pog.dispatch(Increment, event_id: uuid.v4_string) } -fn smoke_subscription(config: pog.Config, connection: pog.Connection) -> Nil { +fn smoke_subscription(connection: pog.Connection) -> Nil { reset_schema(connection) let assert Ok(_) = pog.query( @@ -311,15 +306,18 @@ fn smoke_subscription(config: pog.Config, connection: pog.Connection) -> Nil { )", ) |> pog.execute(on: connection) - let assert Ok(subscription) = - factos_pog.new_projection_subscription( - connection:, - config:, - name: "subscription-smoke", + + let name = process.new_name("test") + let assert Ok(actor.Started(pid: supervisor_pid, ..)) = + static_supervisor.new(strategy: static_supervisor.OneForOne) + |> static_supervisor.add(factos_pog.supervised(name)) + |> static_supervisor.start + let observed_events = process.new_subject() + let strong_projection = + factos_pog.new_subscription( query: factos.AllEvents, - start_from: factos_pog.Origin, - codec: codec(), - project: fn(transaction_connection, recorded) { + consistency: factos_pog.StrongConsistency, + handle: fn(transaction_connection, recorded) { let factos.Recorded(event: Incremented(value:), ..) = recorded pog.query( "insert into factos_dev_projection (singleton, value) @@ -332,8 +330,15 @@ fn smoke_subscription(config: pog.Config, connection: pog.Connection) -> Nil { |> result.map_error(string.inspect) }, ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) + let fire_observer = + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.FireAndForget(name:), + handle: fn(_connection, recorded) { + process.send(observed_events, recorded) + Ok(Nil) + }, + ) let assert Ok(dispatch) = factos_pog.new_dispatch( @@ -342,24 +347,27 @@ fn smoke_subscription(config: pog.Config, connection: pog.Connection) -> Nil { decider: decider(), codec: codec(), ) - |> factos_pog.dispatch_and_wait( - Increment, - event_id: uuid.v4_string, - subscriptions: ["subscription-smoke"], - timeout: duration.seconds(5), - ) + |> factos_pog.with_subscriptions(subscriptions: [ + strong_projection, + fire_observer, + ]) + |> factos_pog.dispatch(Increment, event_id: uuid.v4_string) + + // Strong work is visible as soon as dispatch returns because it committed in + // the same transaction as the recorded event. let assert Ok(returned) = pog.query("select value from factos_dev_projection where singleton = true") |> pog.returning(int_column_decoder()) |> pog.execute(on: connection) let assert [value] = returned.rows assert value == 1 - let assert Ok(factos_pog.SubscriptionStatus(cursor:, events_behind: 0, ..)) = - factos_pog.subscription_status(connection, name: "subscription-smoke") - assert cursor == dispatch.append.position - let assert Ok(Nil) = factos_pog.stop(handle) + let assert [recorded] = dispatch.events + let assert Ok(observed) = process.receive(observed_events, within: 5000) + assert observed == recorded + process.unlink(supervisor_pid) + process.send_abnormal_exit(supervisor_pid, atom.create("shutdown")) Nil } diff --git a/backends/factos_pog/docs/durable-effects.md b/backends/factos_pog/docs/durable-effects.md index e33ea6a..394fecb 100644 --- a/backends/factos_pog/docs/durable-effects.md +++ b/backends/factos_pog/docs/durable-effects.md @@ -1,11 +1,12 @@ # Durable Effects -`factos_pog` does not maintain an effect outbox. Ordinary managed subscribers -consume the authoritative event log from a backend-owned cursor and execute -effects after the event transaction commits. +`FireAndForget(supervisor:)` is not durable delivery. It starts only after a +successful dispatch commit, but missing supervision, a callback panic, or a node +failure can drop the work. It has no catch-up or retry. -Read [How `factos_pog` works](how-it-works.html) for the storage model and -[Durable Subscriptions](durable-subscriptions.html) for the catch-up loop. +For durable external work, use an application-owned transactional outbox. +Read [How `factos_pog` works](how-it-works.html) for the transaction model and +[Subscriptions](subscriptions.html) for callback guarantees. ## Derive effects from decoded events @@ -16,57 +17,81 @@ type Effect { CreditLedger(account_id: String, amount: Int) } -fn ledger_effects(event: Event) -> List(Effect) { - case event { - PaymentCaptured(account_id:, amount:) -> [ - CreditLedger(account_id:, amount:), - ] - PaymentDeclined(account_id: _, reason: _) -> [] - } +fn ledger_reactor() -> factos.Reactor(Event, Effect) { + factos.reactor(fn(recorded) { + case recorded.event { + PaymentCaptured(account_id:, amount:) -> [ + CreditLedger(account_id:, amount:), + ] + PaymentDeclined(account_id: _, reason: _) -> [] + } + }) } ``` -The managed handler receives a complete `factos.Recorded` envelope. It can -derive effect values first, then perform the required IO using stable identity -from the record or domain data. +Pure effect values are easy to test and can be encoded into application-owned +outbox rows without performing external IO in the dispatch transaction. -## Delivery is at least once +## Insert outbox rows with the event -PostgreSQL cannot atomically commit an external HTTP request, message publish, or -email send with a subscription checkpoint. Consider this sequence: +Attach a strong subscription that derives and stores each effect using the +supplied transaction connection: -1. the external service accepts the effect; -2. the subscriber crashes before committing its checkpoint; -3. catch-up reads the same event again after restart. - -The second execution is unavoidable without cooperation from the destination. -Use a deterministic idempotency key for every externally visible operation, for -example: +```gleam +let durable_ledger_effects = + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.StrongConsistency, + handle: fn(transaction_connection, recorded) { + let effects = factos.react(ledger_reactor(), recorded) + ledger_outbox.insert_all( + transaction_connection, + source_event_id: recorded.id, + effects:, + ) + }, + ) -```text -:: +let assert Ok(dispatch) = + factos_pog.new_dispatch( + connection:, + stream: payment_stream, + decider: payment_decider(), + codec: event_codec(), + ) + |> factos_pog.with_subscriptions(subscriptions: [ + durable_ledger_effects, + ]) + |> factos_pog.dispatch(command, event_id: uuid.v4_string) ``` -Send that key to a destination that supports idempotency, or record it in a -uniquely constrained application table before applying a local database effect. -Do not allocate a fresh identifier on each retry. +The accepted event and its outbox rows commit or roll back together. A strong +callback error returns `SubscriptionError(error:)` and rolls back the dispatch. -When one external operation corresponds to one source event, `Recorded.id` is a -stable retry key. Keep a domain-operation key when one logical operation spans -multiple events; using each source id would incorrectly execute it more than -once. +PostgreSQL serialization and deadlock failures can rerun the callback in a new +transaction. Writes from the aborted attempt do not survive. Do not call the +external destination from this callback; that side effect cannot be rolled back. -## Complete one event before returning +Give each outbox operation a deterministic unique identity. When one operation +maps to one source event, combine `Recorded.id` with the effect kind. When one +domain operation spans several events, use the stable domain-operation identity +instead. -The backend advances the subscription cursor only after the handler returns -`Ok(Nil)`. Return success only after all required effects for that event have -completed. If one event produces multiple effects, the application must either: +## Deliver outbox rows after commit -- execute all effects idempotently before returning success; or -- store per-effect completion state so a retry can skip completed effects. +An application worker claims committed outbox rows, sends them to the external +destination, and records completion. This delivery is normally at least once: -Events are processed sequentially. A handler cannot checkpoint a later event -while an earlier event still has unfinished work. +1. the destination accepts an operation; +2. the worker fails before recording completion; +3. the worker claims and sends the same row again. + +Send the deterministic operation identity as the destination's idempotency key. +A fresh key on each attempt defeats idempotency. + +If one row represents multiple external operations, either make every operation +independently idempotent or store per-operation completion state. Do not mark +the row complete while required work remains. ## Retry policy belongs to the integration @@ -75,84 +100,53 @@ Classify errors according to the destination's contract: - retry transient network failures and explicit temporary responses; - honor destination retry deadlines such as `Retry-After`; - stop retrying invalid requests that cannot succeed unchanged; -- bound attempts or age when indefinite retry would block the subscription. - -Backoff, jitter, retry budgets, and deadlines are application policy. A single -backend-wide policy cannot correctly model every external service. - -A poison event in a sequential subscription blocks later events until the -application resolves it. Choose and document one policy: +- bound attempts or age where indefinite retry is unsafe. -- keep retrying while alerting operators; -- record a dead-letter decision and return `Ok(Nil)` so the backend checkpoints it; -- stop the subscriber for operator intervention. +Backoff, jitter, retry budgets, deadlines, leases, and concurrency belong to the +application worker. A single backend-wide policy cannot correctly model every +external service. -If dead letters are required, store them in an application table containing the -subscription name, stable domain operation identity, effect identity, failure, -attempt history, and replay audit. That table represents application policy -rather than a second generic event store. +If dead letters are required, store the stable operation identity, effect +payload, failure, attempt history, and replay audit in application tables. An +operator can then decide whether to retry, replace, or abandon that operation. -## Atomic PostgreSQL projections +## PostgreSQL projections -Use `new_projection_subscription` when the derived state and subscription -checkpoint live in the same PostgreSQL database: +An application-owned PostgreSQL projection does not need an outbox. Update it +directly from a strong subscription: ```gleam -let assert Ok(subscription) = - factos_pog.new_projection_subscription( - connection:, - config:, - name: "user-projection", +let user_projection = + factos_pog.new_subscription( query: factos.AllEvents, - start_from: factos_pog.Origin, - codec: event_codec(), - project: fn(transaction_connection, recorded) { - let factos.Recorded( - id: event_id, - event: UserRegistered(username:), - .., - ) = recorded - pog.query( - "insert into user_projection (event_id, username) values ($1, $2)", - ) - |> pog.parameter(pog.text(event_id)) - |> pog.parameter(pog.text(username)) - |> pog.execute(on: transaction_connection) - |> result.map(fn(_) { Nil }) - |> result.map_error(string.inspect) + consistency: factos_pog.StrongConsistency, + handle: fn(transaction_connection, recorded) { + user_projection.apply(transaction_connection, recorded) }, ) ``` -The backend opens one transaction per matching event, passes its -transaction-scoped connection to `project`, and advances the generation-checked -cursor on that same connection only after `project` returns `Ok(Nil)`. Callback -errors, query errors, crashes, and concurrent resets roll back both changes. -Do not start nested Pog transactions or call external services from `project`. - -Use `Recorded.id` as a source-event uniqueness key when each event contributes -one row. Keep domain-operation keys for projections that combine several events. -Ordinary `new_subscription` handlers remain at least once because their work -finishes before a separate cursor update. +The projection write and event share one transaction. A successful dispatch +return therefore makes that projection immediately query-visible. Use a source +event or domain-operation uniqueness constraint when the projection stores +operation identity. -## Rebuilds and operational replay +## Replay is an application decision -Projection rebuilds can clear derived state and reset the existing subscription -to `Origin`, or consume under a separate stable name when both versions must run -at once. +Projection rebuilds can read historical records with `read_after` and replace +derived tables under an application-controlled checkpoint. -External effects need an explicit replay decision. Replaying historical events -may resend emails, charge accounts, or publish messages. Require an operator to -select the range and effect kinds, preserve original idempotency keys when the -same logical operation is intended, and audit the replay separately. +External-effect replay is more dangerous: replaying events may resend email, +charge accounts, or republish messages. Require an operator to select the range +and effect kinds, preserve original idempotency identities when repeating the +same logical operation, and audit the replay separately. -## Why there is no backend outbox +## Why the outbox is application-owned -The event log already durably records every input needed by pure effect derivation. A -second backend-owned effect log duplicates that durable state and introduces a -large generic API for leasing, retry scheduling, dead letters, replay, worker -supervision, and notification recovery. +Effect schemas, destination contracts, leases, retry schedules, dead letters, +and operational replay are application policy. A generic backend-owned outbox +would either hide those requirements or expose a large integration framework. -Using the event log directly keeps `factos_pog` focused on event-store -correctness. Applications retain the operational controls they need without -being forced into one retry and delivery policy. +`factos_pog` therefore provides the atomic transaction boundary through strong +subscriptions. The application owns the durable rows and the worker that gives +them domain-specific delivery semantics. diff --git a/backends/factos_pog/docs/durable-subscriptions.md b/backends/factos_pog/docs/durable-subscriptions.md deleted file mode 100644 index 03e8f62..0000000 --- a/backends/factos_pog/docs/durable-subscriptions.md +++ /dev/null @@ -1,248 +0,0 @@ -# Durable Subscriptions - -A managed subscription consumes the append-only `factos_events` log from a -backend-owned cursor. PostgreSQL `LISTEN`/`NOTIFY` provides the normal delivery -path; ordered reads from the event log provide recovery after startup, a missed -notification, or a reconnect. - -There is one durable source of truth. `factos_pog` does not copy application -effects into a second outbox table. - -## Configure a subscription - -A subscription needs a stable name, a query, the event codec, and one handler: - -```gleam -let assert Ok(subscription) = - factos_pog.new_subscription( - connection:, - config:, - name: "welcome-email", - query: factos.AllEvents, - start_from: factos_pog.Origin, - codec: event_codec(), - handle: fn(recorded) { - let factos.Recorded(event:, descriptor:, ..) = recorded - case event { - UserRegistered(user_id:, email_address:) -> - welcome_email.send( - user_id:, - email_address:, - metadata: descriptor.metadata, - ) - EmailChanged(email_address: _) -> Ok(Nil) - } - }, - ) -``` - -The name is the durable identity of the consumer. It must be non-blank and -stable across process and application restarts. The backend stores its cursor in -`factos_subscriptions`; application code does not load or update that cursor. - -`config` is the normal Pog configuration. `new_subscription` copies it and -allocates a private notification-session name, so the application does not need -to create a second named configuration. - -The handler receives a complete `factos.Recorded` envelope: stable event id, -stream, revision, global position, domain event, and `factos.EventDescriptor`. -It returns `Ok(Nil)` only after all required work for that event has completed. -Events are handled sequentially in global commit order. - -## Choose the initial cursor - -`start_from` applies only when the stable name has no durable row: - -- `Origin` starts before the first event; -- `Current` stores the event-log head observed after both listeners are active; -- `After(position:)` starts after `NoPosition` or a non-negative position that - is not ahead of the log. - -An existing row always wins over the constructor value. Use -`reset_subscription` to reposition it. Each reset changes the cursor and -increments its generation in one transaction, then wakes a live worker. A -stale worker generation cannot overwrite the reset. - -`subscription_status` returns the cursor, generation, current event-log head, -and `events_behind`. That count is global-log lag, not matching-query lag, -because the cursor advances across non-matching rows. - -## Choose the processing transaction - -`new_subscription` is for ordinary at-least-once handlers, including external -services. The handler completes before a separate cursor write. - -`new_projection_subscription` atomically commits an application-owned -PostgreSQL projection and its cursor: - -```gleam -let assert Ok(subscription) = - factos_pog.new_projection_subscription( - connection:, - config:, - name: "user-projection", - query: factos.AllEvents, - start_from: factos_pog.Origin, - codec: event_codec(), - project: fn(transaction_connection, recorded) { - user_projection.apply(transaction_connection, recorded) - }, - ) -``` - -The projection callback receives a transaction-scoped Pog connection. Its -changes and the generation-checked cursor roll back together on callback error, -query error, crash, or reset race. Process one event per transaction; do not -nestedly call `pog.transaction` or run external services in the callback. - -## Wait for selected projections after dispatch - -Factos command-context consistency protects the facts read by a command before -its event commits. `dispatch_and_wait` is a separate post-commit guarantee: it -waits until each selected durable cursor reaches the dispatch position. - -```gleam -let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "user-renata", - decider: user_decider(), - codec: event_codec(), - ) - |> factos_pog.dispatch_and_wait( - RegisterUser(user_id: "renata"), - event_id: uuid.v4_string, - subscriptions: ["user-projection"], - timeout: duration.seconds(5), - ) -``` - -Successful return means the selected atomic PostgreSQL projections and their -cursors are query-visible. Name only projections the caller will immediately -read. `wait_for_subscriptions` provides the same barrier for an already known -position. - -A commit failure is `DispatchNotCommitted`. Every wait failure is -`DispatchCommittedButNotObserved`, which carries the committed dispatch so the -caller can recover without retrying the command as if it had failed. - -Do not wait for a subscription from a command dispatched inside that same -subscription's callback. The cursor cannot advance until the callback returns, -so the follow-up command commits and its self-wait times out. - -## Start or supervise it - -For development or embedding outside an application supervisor: - -```gleam -let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - -let assert Ok(Nil) = factos_pog.stop(handle) -``` - -`stop` waits for the subscription actor and its notification session to exit. -Call it before shutting down either database connection. In production, -install the child specification in the application tree: - -```gleam -static_supervisor.new(strategy: static_supervisor.OneForOne) -|> static_supervisor.add( - factos_pog.supervised(subscription), -) -|> static_supervisor.start -``` - -Each subscription contains a local `RestForOne` tree. Its dedicated Pog -notification session starts before the subscriber actor. A subscriber failure -restarts the subscriber and reloads its durable cursor. A notification-session -failure restarts both children in dependency order. - -## Delivery path - -Initialization establishes both event and reset `LISTEN` registrations before -loading the checkpoint. This closes the startup race: a commit or reset queued -while catch-up runs is already observable. - -For an envelope smaller than PostgreSQL's notification limit, the trigger sends: - -- the new event cursor and immediately preceding committed cursor; -- event id, stream, and stream revision; -- event type, version, tags, and metadata; -- event JSON data. - -When `previous_cursor` equals the subscriber's current cursor, the notification -is contiguous. The worker applies the query to its type and tags and decodes a -matching event. Ordinary handlers then checkpoint separately; PostgreSQL -projection callbacks and their generation-checked cursor commit in one -transaction. A non-matching contiguous event advances the cursor without -invoking the decoder or callback. - -A notification containing a large event is reduced to its cursor pair so the -append transaction cannot exceed PostgreSQL's payload limit. The worker then -loads that event from `factos_events`. - -The worker also falls back to ordered catch-up reads when it observes a cursor -gap or cannot validate an inline payload. Catch-up reads at most 100 matching -events per query, processes them sequentially, and stores the cursor after every -successful event. It never advances past a handler failure. - -## Why recovery reads remain - -`NOTIFY` is a commit signal, not durable queue storage. Notifications can be -lost while Pog reconnects. If no later event is committed, cursor-gap detection -alone cannot discover the missed event. - -The worker therefore reconciles its durable cursor with the event log every 30 -seconds. In the healthy path this is one low-frequency cursor check; event data -arrives through notifications and is not polled continuously. Startup catch-up, -gap recovery, and reconciliation all read the same authoritative event log. - -Duplicate, delayed, and spurious notifications are harmless: cursors at or -behind the stored position are ignored. Notifications are visible only after -the appending transaction commits. - -## Delivery guarantees - -Ordinary handlers are at least once. PostgreSQL cannot atomically commit their -cursor with an external HTTP request, message publish, or email send: - -1. the handler completes the external effect; -2. the process fails before the cursor update commits; -3. the same recorded event is delivered again after restart. - -Use `Recorded.id` as an idempotency key when one source event maps to one -operation. Use stable domain-operation identity when one operation spans -multiple events. A fresh key on each retry defeats idempotency. - -PostgreSQL projection handlers use the atomic constructor described above, so -their database writes and cursor commit or roll back together. - -A callback error stops the subscriber actor abnormally. Supervision restarts it -from durable state. There is no backend-wide retry schedule or dead-letter -policy; those decisions depend on the integration. A permanently failing event -blocks later matching events until the application handles or operationally -resolves it. - -## Multiple application instances - -Only one running worker should own a subscription name. The cursor row prevents -missing durable state, but it is not a leader-election lock: two workers with the -same name can both execute a handler before either stores the cursor. Use -application-level leader election or a session advisory lock when several -instances may start the same subscription. - -Use different stable names when consumers need independent progress. - -## Filter subscriptions - -A subscription query uses event types and tags. Matching events preserve global -commit order. Values needed for selective routing must be stored as tags; JSON -data is not used in SQL predicates. - -## Custom runtimes - -`listen`, `unlisten`, and `read_after` remain public for applications that -need a different scheduler, batch model, ownership protocol, or checkpoint -schema. Those primitives expose recorded event ids and positions; the managed -subscription keeps its cursor private by design. diff --git a/backends/factos_pog/docs/how-it-works.md b/backends/factos_pog/docs/how-it-works.md index f69f92c..798fa42 100644 --- a/backends/factos_pog/docs/how-it-works.md +++ b/backends/factos_pog/docs/how-it-works.md @@ -1,31 +1,39 @@ # How `factos_pog` Works `factos_pog` is the PostgreSQL event-store backend for Factos. It owns event -persistence, command-context consistency, managed subscription cursors, atomic -PostgreSQL projection checkpoints, ordered recovery reads, notification -sessions, and local subscription supervision. Applications own codecs, -external effects, idempotency, retry policy, and application-level supervision. +persistence, command-context consistency, dispatch-bound subscription +execution, and ordered recovery reads. Applications own codecs, projections, +external effects, durable outboxes, checkpoint policy, and application-level +supervision. ## Dispatch flow -`new_dispatch` receives the event codec and creates command-specific consistency +`new_dispatch` receives an event codec and creates command-specific consistency configuration. Without `with_query`, one stream revision is the consistency boundary. With `with_query`, the boundary is an arbitrary Factos event-type/tag query. A dispatch: -1. opens a `SERIALIZABLE` transaction; +1. opens a PostgreSQL `SERIALIZABLE` transaction; 2. reads and decodes the relevant committed events; 3. folds them into temporary state with the decider's `evolve` function; 4. calls the pure `decide` function; 5. verifies that no relevant event appeared after the observed position; -6. inserts accepted events and their tag-index rows; -7. commits the transaction. +6. inserts accepted event and tag-index rows; +7. filters the accepted records for each strong subscription and runs its + callbacks with the transaction connection; +8. commits the transaction; +9. starts matching fire-and-forget subscriptions as supervised temporary work. + +`Dispatch.events` is the append-ordered list of records accepted by that +dispatch. No-event commands invoke no subscription callbacks. Serialization and deadlock conflicts retry up to the builder's configured -attempt count. Deciders, codecs, and event-id generators can therefore run more -than once and must be pure. External work starts only after commit. +attempt count. Deciders, codecs, event-id generators, and strong subscription +callbacks can therefore run more than once. Deciders, codecs, and event-id +generators must be pure. Strong callbacks must keep side effects on the supplied +transaction connection so an aborted attempt rolls them back. ## Storage model @@ -35,12 +43,12 @@ stream revision, type, version, tags, JSONB metadata, and JSONB event data. queries. The unique `(stream, revision)` constraint protects stream order. Event ids must -be UUIDv4 strings and are globally unique. A global identity position gives all -subscribers one stable order. +be UUIDv4 strings and are globally unique. A transaction-scoped append lock is +acquired before identity values are allocated, so global event positions follow +commit order rather than the order of aborted insert attempts. -The event log is also the durable source for projections, process managers, and -integration-effect subscribers. `factos_pog` does not persist a second, -reconstructible copy of derived effects. +The schema contains no subscription table, cursor, checkpoint, projection, or +effect outbox. ## Command-context consistency @@ -55,110 +63,48 @@ transaction aborts instead of allowing both commands to accept stale context. For stream dispatch, the current stream revision is the append boundary. -Command-context consistency ends when the event transaction commits. It does -not imply that a derived projection has caught up. - -`dispatch_and_wait` adds that separate read-after-write guarantee. After a -successful dispatch it polls all selected stable subscription names in one -cursor query and returns only when every cursor reaches the dispatch position. -A commit failure is distinct from a post-commit wait failure; the latter carries -the committed `Dispatch`. - -Empty name lists and no-event dispatches return without reading subscription -state. Polling removes duplicate names while preserving first-occurrence order, -uses a bounded 20-millisecond interval, and reports only lagging -`SubscriptionStatus` values on timeout. - -## Managed durable subscriptions - -`new_subscription` and `new_projection_subscription` bind a stable name to an -event query, codec, initial cursor, and callback. The backend owns the name's -cursor and generation in `factos_subscriptions`. - -`start` starts a local supervision tree and returns a stoppable handle. `stop` -waits for that complete tree to terminate. `supervised` returns the same tree as -a permanent child specification for an application supervisor. The tree uses -`RestForOne`: the notification session starts first, a subscriber failure -restarts only the subscriber, and a notification-session failure restarts both -children. - -The managed lifecycle is: - -1. establish `LISTEN` for events and subscription resets; -2. load the durable cursor and generation, or create them from `Origin`, - `Current`, or `After(position:)` on the first start; -3. read and process existing events in global position order; -4. handle contiguous committed events directly from full recorded envelopes; -5. generation-check each cursor advance; -6. use ordered reads after a notification gap or cursor-only notification; -7. every 30 seconds, reload durable state and reconcile missed work; -8. after a failure or reset, reload the authoritative checkpoint before retrying. - -Listening before loading the cursor closes the startup race: a commit after -`LISTEN` queues a notification while catch-up runs. - -The event trigger notifies `factos_pog_events` after each inserted row. A normal -payload contains the new and previous cursor plus the event descriptor and JSON -data. PostgreSQL exposes it only after commit. If the encoded envelope would -exceed PostgreSQL's notification payload limit, the trigger sends only the two -cursors and the worker loads the event from `factos_events`. - -The previous cursor makes the inline fast path safe. It must equal the worker's -current cursor; otherwise the worker reads the missing range from the durable -log before proceeding. Duplicate and delayed notifications at or behind the -cursor are ignored. - -`NOTIFY` is not durable while a client is disconnected. A later notification -reveals a gap, but no later commit may occur, so low-frequency reconciliation is -still required for eventual processing without restarting the application. The -event log and cursor provide correctness; the notification transports the -common case without another event-row read. - -`listen`, `unlisten`, and `read_after` remain available for custom -subscription runtimes. - -## Checkpoints and concurrency - -The managed worker processes matching events sequentially and never advances -past failed work. Ordinary handlers complete before a separate cursor update. -Projection handlers update application rows and the cursor in one PostgreSQL -transaction. - -Every cursor write includes the generation loaded by the worker. A reset changes -the cursor and increments generation transactionally, so an in-flight stale -worker cannot overwrite it. Reset notifications are only a wake-up path; -periodic durable reload recovers a missed notification. - -One running worker should own each subscription name. The cursor row is durable -state, not a leader-election lock; multiple application instances must use -application-level leader election or a session advisory lock if they can start -the same consumer. - -Parallel event handling requires a separate contiguous-completion or -gap-tracking model and therefore belongs in a custom runtime. +Command-context consistency ends when the event transaction commits. A strong +subscription can place a PostgreSQL projection inside that same boundary. +Separately maintained projections have their own consistency model. + +## Dispatch-bound subscriptions + +`new_subscription` combines a Factos query, one consistency mode, and a callback. +The dispatch codec has already decoded all accepted records. Filtering uses +their event descriptors, and matching callbacks receive the complete +`factos.Recorded` envelope. + +Strong subscriptions run in list order. Within one subscription, matching +records run in event append order. The first callback `Error` becomes +`SubscriptionError(error:)`; the surrounding transaction rolls back all +accepted events and every strong callback write. Callback errors are not +retryable, while backend serialization and deadlock errors are. + +Fire-and-forget subscriptions are enqueued only after the final commit. Each +matching subscription gets one temporary child under the application-installed +`SubscriptionSupervisor`; that child processes its matching records in append +order. Separate subscriptions and dispatches may run concurrently. + +A fire callback's return value cannot alter the committed result. Returned +errors are ignored and processing continues. A panic terminates the temporary +child and drops its remaining records. Missing or restarting supervision also +drops the work. There is no catch-up or retry. + +## Ordered recovery reads + +`read_after` loads matching records from the durable event log in global +position order. A custom durable consumer polls this API, persists an +application-owned checkpoint, and defines its own ownership, batching, +concurrency, retry, and poison-event policy. ## Effects and delivery guarantees -Every callback receives the full `factos.Recorded` envelope, including id, -stream, revision, global position, descriptor, and domain event. - -`new_subscription` is at least once. For an external service, no atomic -transaction spans PostgreSQL and that service. A crash after external success -but before cursor advancement causes redelivery. Use `Recorded.id` when one -source event maps to one operation, and domain-operation identity when one -operation spans several events. - -`new_projection_subscription` opens one Pog transaction per matching event, -passes its scoped connection to the callback, and writes the generation-checked -cursor only after the callback succeeds. Callback errors, query errors, crashes, -and reset races roll back both the application projection and checkpoint. - -Retries, backoff, dead letters, poison-event handling, and operational replay are -application policy. Keeping those decisions outside `factos_pog` avoids a -second durable effect log and allows each integration to choose the policy its -external system actually supports. - -Callers should wait only for projections they will immediately query. A -subscription callback must not dispatch a command that waits for the same -subscription name: its cursor cannot advance until the callback returns, so the -nested wait can only time out after committing. +No transaction can atomically commit PostgreSQL rows and an external HTTP +request, message publish, or email send. Fire-and-forget work is therefore +best-effort. + +For durable external delivery, a strong callback can insert an +application-owned outbox row using the dispatch transaction connection. The +event, projection changes, and outbox row then commit or roll back together. An +application worker owns delivery retries, backoff, idempotency, dead letters, +and operational replay. diff --git a/backends/factos_pog/docs/subscriptions.md b/backends/factos_pog/docs/subscriptions.md new file mode 100644 index 0000000..ab09bea --- /dev/null +++ b/backends/factos_pog/docs/subscriptions.md @@ -0,0 +1,171 @@ +# Subscriptions + +`factos_pog` subscriptions bind callbacks to one command dispatch. They filter +the decoded records in `Dispatch.events`; they do not own a cursor or consume +historical events. + +Choose the consistency mode from the callback's required commit boundary: + +- `StrongConsistency` runs inside the dispatch transaction. +- `FireAndForget(supervisor:)` starts supervised best-effort work after commit. + +## Configure and attach subscriptions + +Queries use event types and tags. Values needed for selective routing must be +stored as tags because subscription filtering does not inspect event JSON: + +```gleam +let registrations = + factos.query([ + factos.query_item( + types: [factos.event_type("UserRegistered")], + tags: [], + ), + ]) + +let user_projection_subscription = + factos_pog.new_subscription( + query: registrations, + consistency: factos_pog.StrongConsistency, + handle: fn(transaction_connection, recorded) { + user_projection.apply(transaction_connection, recorded) + }, + ) +``` + +`new_subscription` is infallible. The codec attached to the dispatch has +already decoded each `factos.Recorded` envelope before subscription filtering. +The callback receives the complete event id, stream, revision, global position, +descriptor, metadata, tags, and domain event. + +Attach the subscriptions to a dispatch builder: + +```gleam +let assert Ok(dispatch) = + factos_pog.new_dispatch( + connection:, + stream: "user-renata", + decider: user_decider(), + codec: event_codec(), + ) + |> factos_pog.with_subscriptions(subscriptions: [ + user_projection_subscription, + ]) + |> factos_pog.dispatch( + RegisterUser(user_id: "renata"), + event_id: uuid.v4_string, + ) +``` + +`with_subscriptions` replaces the builder's complete list. Every subscription +in that list must use the same callback error type. + +Subscriptions only receive records accepted by this dispatch. They do not +replay older rows or observe events committed by other dispatches. + +## Strong consistency + +Strong callbacks run after the event and tag rows have been inserted but before +the transaction commits. Use the supplied transaction connection for all +PostgreSQL work: + +```gleam +let projection = + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.StrongConsistency, + handle: fn(transaction_connection, recorded) { + user_projection.apply(transaction_connection, recorded) + }, + ) +``` + +The backend traverses subscriptions in list order and matching records in event +append order. A callback can therefore observe writes made by an earlier strong +callback in the same dispatch. + +The first returned `Error` becomes +`factos_pog.SubscriptionError(error: callback_error)`. PostgreSQL rolls back: + +- all event and tag rows accepted by the command; +- the failing callback's database writes; +- every earlier strong callback write in that transaction. + +Callback errors are not retryable. PostgreSQL serialization failures and +deadlocks remain retryable, so a strong callback may run again after an aborted +attempt. Its external effects cannot be rolled back. Keep all side effects on +the supplied transaction connection. + +An empty dispatch and a non-matching query invoke no callback. + +## Fire and forget + +Allocate one opaque subscription supervisor when the application starts: + +```gleam +let subscription_supervisor = factos_pog.new_subscription_supervisor() +``` + +Install its child specification in the application supervision tree. There is +deliberately no unsupervised convenience starter: + +```gleam +static_supervisor.new(strategy: static_supervisor.OneForOne) +|> static_supervisor.add( + factos_pog.supervised_subscription_supervisor(subscription_supervisor), +) +|> static_supervisor.start +``` + +Pass that same value to each fire-and-forget subscription: + +```gleam +let observer = + factos_pog.new_subscription( + query: registrations, + consistency: factos_pog.FireAndForget( + supervisor: subscription_supervisor, + ), + handle: fn(connection, recorded) { + registration_observer.handle(connection, recorded) + }, + ) +``` + +After a successful final commit, the backend starts one supervised temporary +child per matching fire-and-forget subscription. Each child invokes its +callback for matching records in append order. Different subscriptions and +different dispatches can run concurrently, so there is no global ordering +guarantee. + +The dispatch does not wait for these callbacks. Their `Ok` or `Error` result +cannot change the committed dispatch; a returned `Error` is ignored and the +child continues with later records. A panic terminates that temporary child and +drops its remaining records. Temporary children are never restarted. + +If the supervisor is missing or restarting, the already committed dispatch +still succeeds and the work is dropped. + +Fire-and-forget has no cursor, catch-up, checkpoint, reset, replay, retry +schedule, or dead-letter policy. It is suitable only for best-effort work. + +## Durable external work + +An email, HTTP request, or message publish cannot commit atomically with the +PostgreSQL event transaction. Fire-and-forget therefore cannot provide durable +external delivery. + +For durable effects, have a strong callback insert an application-owned outbox +row with the event transaction. A separate application worker can deliver that +row with its own retry and idempotency policy. See +[Durable effects](durable-effects). + +## Custom consumer runtimes + +`read_after` remains public for applications that need historical catch-up, +batching, or an independent checkpoint protocol. A custom durable runtime polls +ordered records, persists its own checkpoint, and resumes from that checkpoint +after startup or failure. + +This API does not impose a scheduler, ownership model, retry policy, or +checkpoint schema. diff --git a/backends/factos_pog/gleam.toml b/backends/factos_pog/gleam.toml index 6de4a29..a53644e 100644 --- a/backends/factos_pog/gleam.toml +++ b/backends/factos_pog/gleam.toml @@ -21,9 +21,9 @@ path = "how-it-works.html" source = "./docs/how-it-works.md" [[documentation.pages]] -title = "Durable Subscriptions" -path = "durable-subscriptions.html" -source = "./docs/durable-subscriptions.md" +title = "Subscriptions" +path = "subscriptions.html" +source = "./docs/subscriptions.md" [[documentation.pages]] title = "Durable Effects" @@ -33,11 +33,11 @@ source = "./docs/durable-effects.md" [dependencies] factos = { path = "../.." } gleam_stdlib = ">= 1.0.0 and < 2.0.0" -pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } -gleam_time = ">= 1.8.0 and < 2.0.0" gleam_erlang = ">= 1.0.0 and < 2.0.0" gleam_json = ">= 3.1.0 and < 4.0.0" gleam_otp = ">= 1.2.0 and < 2.0.0" +pog = ">= 4.1.0 and < 5.0.0" +exception = ">= 2.1.1 and < 3.0.0" [dev_dependencies] envoy = ">= 1.0.0 and < 2.0.0" diff --git a/backends/factos_pog/manifest.toml b/backends/factos_pog/manifest.toml index b2700f1..9e5664c 100644 --- a/backends/factos_pog/manifest.toml +++ b/backends/factos_pog/manifest.toml @@ -35,7 +35,7 @@ packages = [ { name = "opentelemetry_api", version = "1.5.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "F53EC8A1337AE4A487D43AC89DA4BD3A3C99DDF576655D071DEED8B56A2D5DDA" }, { name = "pg_types", version = "0.6.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "9949A4849DD13408FA249AB7B745E0D2DFDB9532AEE2B9722326E33CD082A778" }, { name = "pgo", version = "0.20.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "2F11E6649CEB38E569EF56B16BE1D04874AE5B11A02867080A2817CE423C683B" }, - { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], source = "git", repo = "https://github.com/foxfriends/pog.git", commit = "919fd6ac96095ea11fa7c940b17eaece49cc5993" }, + { name = "pog", version = "4.1.0", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_otp", "gleam_stdlib", "gleam_time", "pgo"], otp_app = "pog", source = "hex", outer_checksum = "E4AFBA39A5FAA2E77291836C9683ADE882E65A06AB28CA7D61AE7A3AD61EBBD5" }, { name = "prng", version = "5.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "prng", source = "hex", outer_checksum = "29DA88BCFB54D06DD1472951DF101E9524878056D139DA2616B04350B403CE10" }, { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, { name = "simplifile", version = "2.5.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "6C72DCCDF25C38A5931740B30E823969F33106831FD1637719B5EDBCA30027A4" }, @@ -51,16 +51,16 @@ packages = [ [requirements] envoy = { version = ">= 1.0.0 and < 2.0.0" } +exception = { version = ">= 2.1.1 and < 3.0.0" } factos = { path = "../.." } gleam_erlang = { version = ">= 1.0.0 and < 2.0.0" } gleam_json = { version = ">= 3.1.0 and < 4.0.0" } gleam_otp = { version = ">= 1.2.0 and < 2.0.0" } gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } -gleam_time = { version = ">= 1.8.0 and < 2.0.0" } gleamy_bench = { version = ">= 0.6.0 and < 1.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } global_value = { version = ">= 1.0.0 and < 2.0.0" } -pog = { git = "https://github.com/foxfriends/pog.git", ref = "919fd6ac96095ea11fa7c940b17eaece49cc5993" } +pog = { version = ">= 4.1.0 and < 5.0.0" } simplifile = { version = ">= 2.5.0 and < 3.0.0" } testcontainer = { version = ">= 1.0.2 and < 2.0.0" } testcontainer_formulas = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/backends/factos_pog/priv/dbmate/20260816000100_factos_pog_v2.sql b/backends/factos_pog/priv/dbmate/20260816000100_factos_pog_v2.sql index 4f956f6..e6545be 100644 --- a/backends/factos_pog/priv/dbmate/20260816000100_factos_pog_v2.sql +++ b/backends/factos_pog/priv/dbmate/20260816000100_factos_pog_v2.sql @@ -7,11 +7,6 @@ alter table factos_events alter column metadata type jsonb using metadata::jsonb, alter column data type jsonb using convert_from(data, 'UTF8')::jsonb; -create table factos_subscriptions ( - name text primary key check (btrim(name) <> ''), - cursor bigint not null default -1 check (cursor >= -1), - generation bigint not null default 0 check (generation >= 0) -); -- Sequence values are allocated before commit. Taking this transaction-scoped -- lock before defaults are evaluated makes event positions commit ordered. @@ -30,64 +25,9 @@ before insert on factos_events for each statement execute function factos_pog_lock_event_append(); -create function factos_pog_notify_event_appended() -returns trigger -language plpgsql -as $function$ -declare - event_payload text; - previous_cursor bigint; -begin - select coalesce(max(position), -1) - into previous_cursor - from factos_events - where position < new.position; - - event_payload := jsonb_build_object( - 'cursor', new.position, - 'previous_cursor', previous_cursor, - 'event', jsonb_build_object( - 'id', new.id, - 'stream', new.stream, - 'revision', new.revision, - 'type', new.type, - 'version', new.version, - 'tags', case - when new.tags = '' then '[]'::jsonb - when left(ltrim(new.tags), 1) = '[' then new.tags::jsonb - else to_jsonb(string_to_array(btrim(new.tags, E'\n'), E'\n')) - end, - 'metadata', new.metadata, - 'data', new.data - ) - )::text; - - if octet_length(event_payload) < 8000 then - perform pg_catalog.pg_notify('factos_pog_events', event_payload); - else - perform pg_catalog.pg_notify( - 'factos_pog_events', - jsonb_build_object( - 'cursor', new.position, - 'previous_cursor', previous_cursor - )::text - ); - end if; - return new; -end; -$function$; - -create trigger factos_pog_event_insert_notify -after insert on factos_events -for each row -execute function factos_pog_notify_event_appended(); - -- migrate:down -drop trigger if exists factos_pog_event_insert_notify on factos_events; -drop function if exists factos_pog_notify_event_appended(); drop trigger if exists factos_pog_event_append_lock on factos_events; drop function if exists factos_pog_lock_event_append(); -drop table if exists factos_subscriptions; alter table factos_events alter column metadata type text using metadata::text, diff --git a/backends/factos_pog/priv/migrations.sql b/backends/factos_pog/priv/migrations.sql index a337c53..9a11e70 100644 --- a/backends/factos_pog/priv/migrations.sql +++ b/backends/factos_pog/priv/migrations.sql @@ -38,11 +38,6 @@ cross join lateral regexp_split_to_table(factos_events.tags, E'\n') as split_tag where split_tags.tag <> '' on conflict do nothing; -create table if not exists factos_subscriptions ( - name text primary key check (btrim(name) <> ''), - cursor bigint not null default -1 check (cursor >= -1), - generation bigint not null default 0 check (generation >= 0) -); create or replace function factos_pog_lock_event_append() returns trigger @@ -60,55 +55,3 @@ before insert on factos_events for each statement execute function factos_pog_lock_event_append(); -create or replace function factos_pog_notify_event_appended() -returns trigger -language plpgsql -as $function$ -declare - event_payload text; - previous_cursor bigint; -begin - select coalesce(max(position), -1) - into previous_cursor - from factos_events - where position < new.position; - - event_payload := jsonb_build_object( - 'cursor', new.position, - 'previous_cursor', previous_cursor, - 'event', jsonb_build_object( - 'id', new.id, - 'stream', new.stream, - 'revision', new.revision, - 'type', new.type, - 'version', new.version, - 'tags', case - when new.tags = '' then '[]'::jsonb - when left(ltrim(new.tags), 1) = '[' then new.tags::jsonb - else to_jsonb(string_to_array(btrim(new.tags, E'\n'), E'\n')) - end, - 'metadata', new.metadata, - 'data', new.data - ) - )::text; - - if octet_length(event_payload) < 8000 then - perform pg_catalog.pg_notify('factos_pog_events', event_payload); - else - perform pg_catalog.pg_notify( - 'factos_pog_events', - jsonb_build_object( - 'cursor', new.position, - 'previous_cursor', previous_cursor - )::text - ); - end if; - return new; -end; -$function$; - -drop trigger if exists factos_pog_event_insert_notify on factos_events; -create trigger factos_pog_event_insert_notify -after insert on factos_events -for each row -execute function factos_pog_notify_event_appended(); diff --git a/backends/factos_pog/src/factos/factos_pog.gleam b/backends/factos_pog/src/factos/factos_pog.gleam index 15135ed..54ccef2 100644 --- a/backends/factos_pog/src/factos/factos_pog.gleam +++ b/backends/factos_pog/src/factos/factos_pog.gleam @@ -17,20 +17,15 @@ import factos import gleam/dict import gleam/dynamic/decode -import gleam/erlang/atom import gleam/erlang/process -import gleam/erlang/reference import gleam/int import gleam/json import gleam/list -import gleam/option import gleam/otp/actor -import gleam/otp/static_supervisor +import gleam/otp/factory_supervisor import gleam/otp/supervision import gleam/result import gleam/string -import gleam/time/duration -import gleam/time/timestamp import pog /// A domain event prepared for PostgreSQL persistence. @@ -122,16 +117,13 @@ pub type Dispatch(event) { Dispatch(append: Append, events: List(factos.Recorded(event))) } -/// A dispatch failure before commit or a post-commit observation failure. -pub type DispatchWaitError(event, domain_error) { - DispatchNotCommitted(error: Error(domain_error)) - DispatchCommittedButNotObserved( - dispatch: Dispatch(event), - reason: SubscriptionError, - ) -} - -pub opaque type DispatchBuilder(command, state, event, domain_error) { +pub opaque type DispatchBuilder( + command, + state, + event, + domain_error, + subscription_error, +) { DispatchBuilder( connection: pog.Connection, stream: String, @@ -139,157 +131,30 @@ pub opaque type DispatchBuilder(command, state, event, domain_error) { decider: factos.Decider(command, state, event, domain_error), codec: EventCodec(event), retry_attempts: Int, + subscriptions: List(Subscription(event, subscription_error)), ) } -/// Initial durable cursor used only when a subscription name is first created. -pub type SubscriptionStart { - Origin - Current - After(position: factos.SequencePosition) -} - -/// Durable progress and global event-log lag for a managed subscription. -pub type SubscriptionStatus { - SubscriptionStatus( - name: String, - cursor: factos.SequencePosition, - generation: Int, - event_log_position: factos.SequencePosition, - events_behind: Int, +/// When a subscription callback executes relative to the event commit. +pub type SubscriptionConsistency { + FireAndForget( + name: process.Name(factory_supervisor.Message(fn() -> Nil, Nil)), ) + StrongConsistency } -/// Failure while configuring or coordinating a managed subscription. -pub type SubscriptionError { - InvalidSubscriptionName(name: String) - InvalidSubscriptionPosition(position: factos.SequencePosition) - SubscriptionNotFound(name: String) - SubscriptionStartAfterEventLog( - name: String, - requested: factos.SequencePosition, - event_log_position: factos.SequencePosition, - ) - SubscriptionStoreError(error: pog.QueryError) - SubscriptionWaitTimedOut( - through: factos.SequencePosition, - pending: List(SubscriptionStatus), - ) -} - -type SubscriptionHandler(event, processing_error) { - OrdinarySubscriptionHandler( - handle: fn(factos.Recorded(event)) -> Result(Nil, processing_error), - ) - PostgresProjectionHandler( - project: fn(pog.Connection, factos.Recorded(event)) -> - Result(Nil, processing_error), - ) -} - -/// A managed durable event subscription. +/// A dispatch-bound event subscription. /// -/// The subscription owns notification lifecycle and its durable cursor. -/// Application code handles complete recorded event envelopes. -pub opaque type Subscription(event, processing_error) { - Builder( - name: String, - queries_connection: pog.Connection, - listen_config: pog.Config, +/// The query filters the already-decoded events produced by one dispatch. +pub opaque type Subscription(event, subscription_error) { + Subscription( query: factos.Query, - start_from: SubscriptionStart, - codec: EventCodec(event), - handler: SubscriptionHandler(event, processing_error), - ) -} - -/// Handle for a subscription started outside an application supervisor. -pub opaque type SubscriptionHandle { - SubscriptionHandle(pid: process.Pid) -} - -/// Failure while synchronously stopping a started subscription. -pub type SubscriptionStopError { - SubscriptionStopTimedOut -} - -type SubscriptionCheckpoint { - SubscriptionCheckpoint(cursor: factos.SequencePosition, generation: Int) -} - -type SubscriptionCheckpointWriteError { - SubscriptionGenerationChanged - SubscriptionCheckpointError(error: SubscriptionError) -} - -type ProjectionTransactionError(processing_error) { - ProjectionCallbackFailed(error: processing_error) - ProjectionCheckpointFailed(error: SubscriptionCheckpointWriteError) -} - -type SubscriptionState(event, processing_error) { - SubscriptionState( - subscription: Subscription(event, processing_error), - subject: process.Subject(SubscriptionMessage), - checkpoint: SubscriptionCheckpoint, - notifications_pid: process.Pid, - event_reference: reference.Reference, - reset_reference: reference.Reference, - monitor: process.Monitor, - timer: option.Option(process.Timer), + consistency: SubscriptionConsistency, + handle: fn(pog.Connection, factos.Recorded(event)) -> + Result(Nil, subscription_error), ) } -type SubscriptionMessage { - CheckSubscription - SubscriptionNotification(notification: pog.Notification) - SubscriptionParentExit(message: process.ExitMessage) - SubscriptionListenerDown(down: process.Down) -} - -type RoutedSubscriptionNotification { - EventNotificationPayload(payload: String) - ResetNotificationPayload(payload: String) -} - -type ResetNotification { - ResetNotification(name: String, generation: Int) -} - -type NotificationRouting { - NotificationRouting( - cursor: factos.SequencePosition, - previous_cursor: factos.SequencePosition, - type_: factos.EventType, - tags: List(factos.Tag), - ) -} - -type NotificationDescriptor { - NotificationDescriptor( - cursor: factos.SequencePosition, - previous_cursor: factos.SequencePosition, - id: String, - stream: String, - revision: Int, - descriptor: factos.EventDescriptor, - ) -} - -type InlineNotification(event) { - InlineNotification( - cursor: factos.SequencePosition, - previous_cursor: factos.SequencePosition, - event: factos.Recorded(event), - ) -} - -const subscription_batch_size = 100 - -const subscription_reconciliation_interval_milliseconds = 30_000 - -const subscription_reset_channel = "factos_pog_subscription_resets" - type DispatchQuery { StreamQuery ContextQuery(factos.Query) @@ -297,17 +162,17 @@ type DispatchQuery { /// Start building an event dispatch. /// -/// By default the builder uses one-stream consistency and 5 attempts for -/// retryable serializable transaction conflicts. +/// By default the builder uses one-stream consistency, 5 attempts for retryable +/// serializable transaction conflicts, and no subscriptions. /// -/// WARNING: every function passed into dispatch must be pure. External work -/// belongs in a durable event subscription after commit. +/// WARNING: decider and codec functions must be pure. Strong subscription +/// callbacks may run again after a serialization or deadlock retry. pub fn new_dispatch( connection connection: pog.Connection, stream stream_name: String, decider decider: factos.Decider(command, state, event, domain_error), codec codec: EventCodec(event), -) -> DispatchBuilder(command, state, event, domain_error) { +) -> DispatchBuilder(command, state, event, domain_error, Nil) { DispatchBuilder( connection:, stream: stream_name, @@ -315,55 +180,67 @@ pub fn new_dispatch( decider:, codec:, retry_attempts: 5, + subscriptions: [], ) } /// Use a context query instead of one-stream consistency. pub fn with_query( - builder: DispatchBuilder(command, state, event, domain_error), + builder: DispatchBuilder( + command, + state, + event, + domain_error, + subscription_error, + ), query query: factos.Query, -) -> DispatchBuilder(command, state, event, domain_error) { - let DispatchBuilder( - connection:, - stream:, - decider:, - codec:, - retry_attempts:, - .., - ) = builder - DispatchBuilder( - connection:, - stream:, - query: ContextQuery(query), - decider:, - codec:, - retry_attempts:, - ) +) -> DispatchBuilder(command, state, event, domain_error, subscription_error) { + DispatchBuilder(..builder, query: ContextQuery(query)) +} + +/// Replace the subscriptions attached to this dispatch. +/// +/// Replacing the complete list lets the builder adopt the callbacks' shared +/// error type without an error-conversion wrapper. +pub fn with_subscriptions( + builder: DispatchBuilder( + command, + state, + event, + domain_error, + previous_subscription_error, + ), + subscriptions subscriptions: List(Subscription(event, subscription_error)), +) -> DispatchBuilder(command, state, event, domain_error, subscription_error) { + DispatchBuilder(..builder, subscriptions:) } /// Override retry attempts for PostgreSQL serializable/deadlock conflicts. pub fn with_retry_attempts( - builder: DispatchBuilder(command, state, event, domain_error), + builder: DispatchBuilder( + command, + state, + event, + domain_error, + subscription_error, + ), attempts attempts: Int, -) -> DispatchBuilder(command, state, event, domain_error) { - let DispatchBuilder(connection:, stream:, query:, decider:, codec:, ..) = - builder - DispatchBuilder( - connection:, - stream:, - query:, - decider:, - codec:, - retry_attempts: int.max(attempts, 1), - ) +) -> DispatchBuilder(command, state, event, domain_error, subscription_error) { + DispatchBuilder(..builder, retry_attempts: int.max(attempts, 1)) } /// Dispatch a command and atomically append its accepted events. pub fn dispatch( - builder: DispatchBuilder(command, state, event, domain_error), + builder: DispatchBuilder( + command, + state, + event, + domain_error, + subscription_error, + ), command: command, event_id event_id: fn() -> String, -) -> Result(Dispatch(event), Error(domain_error)) { +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { let DispatchBuilder( connection:, stream:, @@ -371,9 +248,10 @@ pub fn dispatch( decider:, codec:, retry_attempts:, + subscriptions:, ) = builder - case query { + let result = case query { StreamQuery -> dispatch_stream( connection, @@ -383,6 +261,7 @@ pub fn dispatch( command:, event_id:, retry_attempts:, + subscriptions:, ) ContextQuery(query) -> dispatch_query( @@ -394,1266 +273,154 @@ pub fn dispatch( command:, event_id:, retry_attempts:, - ) - } -} - -/// Dispatch a command, then wait for selected durable subscriptions. -/// -/// A post-commit wait failure carries the committed dispatch so callers never -/// mistake an observation timeout for a failed command. -pub fn dispatch_and_wait( - builder: DispatchBuilder(command, state, event, domain_error), - command: command, - event_id event_id: fn() -> String, - subscriptions subscriptions: List(String), - timeout timeout: duration.Duration, -) -> Result(Dispatch(event), DispatchWaitError(event, domain_error)) { - let DispatchBuilder(connection:, ..) = builder - case dispatch(builder, command, event_id:) { - Error(error) -> Error(DispatchNotCommitted(error:)) - Ok(dispatch) -> - case - wait_for_subscriptions( - connection, - subscriptions:, - through: dispatch.append.position, - timeout:, - ) - { - Ok(Nil) -> Ok(dispatch) - Error(reason) -> - Error(DispatchCommittedButNotObserved(dispatch:, reason:)) - } - } -} - -/// Wait until every named subscription cursor has observed `through`. -pub fn wait_for_subscriptions( - connection: pog.Connection, - subscriptions subscriptions: List(String), - through through: factos.SequencePosition, - timeout timeout: duration.Duration, -) -> Result(Nil, SubscriptionError) { - case subscriptions, through { - [], _ -> Ok(Nil) - _, factos.NoPosition -> Ok(Nil) - [_, ..], factos.SequencePosition(position) -> - case position < 0 { - True -> Error(InvalidSubscriptionPosition(position: through)) - False -> { - let subscriptions = list.unique(subscriptions) - use _ <- result.try(validate_subscription_names(subscriptions)) - let timeout = non_negative_wait_duration(timeout) - let deadline = timestamp.add(timestamp.system_time(), timeout) - wait_for_subscription_cursors( - connection, - subscriptions, - through, - deadline, - ) - } - } - } -} - -pub type Error(domain_error) { - /// The decider rejected the command with a domain error. - DomainError(domain_error) - - /// PostgreSQL or `pog` returned an error while running a query. - StoreError(pog.QueryError) - - /// A stream revision or context append condition failed. - AppendConditionFailed(factos.AppendCondition) - DecodeError(DecodeError) -} - -pub type DecodeError { - UnknownEvent - InvalidData -} - -type QuerySql { - QuerySql(sql: String, parameters: List(QueryParameter)) -} - -type QueryParameter { - IntParameter(Int) - TextParameter(String) -} - -const event_notification_channel = "factos_pog_events" - -@external(erlang, "factos_pog_ffi", "listen") -fn listen_on_channel( - connection: pog.NotificationsConnection, - channel: String, -) -> Result(reference.Reference, Nil) - -@external(erlang, "factos_pog_ffi", "stop_notifications") -fn stop_notifications(pid: process.Pid) -> Nil - -/// Subscribe the current process to committed-event availability hints. -/// -/// PostgreSQL notifications are advisory and may be coalesced or missed while -/// disconnected. Establish the listener before reading durable events after the -/// subscription checkpoint, and repeat that catch-up after every reconnect. -@internal -pub fn listen( - connection: pog.NotificationsConnection, -) -> Result(reference.Reference, Nil) { - listen_on_channel(connection, event_notification_channel) -} - -/// Stop a committed-event notification subscription. -pub fn unlisten( - connection: pog.NotificationsConnection, - listener: reference.Reference, -) -> Nil { - pog.unlisten(connection, listener) -} - -/// Configure a managed durable event subscription. -/// -/// The worker establishes `LISTEN`, catches up once from its durable cursor, and -/// then handles matching inline event notifications without rereading their -/// event rows. Recovery reads run after startup, a notification gap, or periodic -/// reconnect reconciliation. -/// -/// `handle` receives the complete recorded event envelope. After it returns -/// `Ok(Nil)`, the worker stores the private cursor. A crash between an external -/// side effect and that cursor update can replay the event, so external effects -/// must be idempotent. -pub fn new_subscription( - connection connection: pog.Connection, - config config: pog.Config, - name name: String, - query query: factos.Query, - start_from start_from: SubscriptionStart, - codec codec: EventCodec(event), - handle handle: fn(factos.Recorded(event)) -> Result(Nil, processing_error), -) -> Result(Subscription(event, processing_error), SubscriptionError) { - use _ <- result.try(validate_subscription_name(name)) - Ok(Builder( - name:, - listen_config: config, - queries_connection: connection, - query:, - start_from:, - codec:, - handler: OrdinarySubscriptionHandler(handle:), - )) -} - -/// Configure a subscription whose projection and cursor commit atomically. -/// -/// `project` runs once per matching event inside a Pog transaction. Use only -/// the supplied transaction-scoped connection and do not start a nested -/// transaction or call external services from the callback. -pub fn new_projection_subscription( - connection connection: pog.Connection, - config config: pog.Config, - name name: String, - query query: factos.Query, - start_from start_from: SubscriptionStart, - codec codec: EventCodec(event), - project project: fn(pog.Connection, factos.Recorded(event)) -> - Result(Nil, processing_error), -) -> Result(Subscription(event, processing_error), SubscriptionError) { - use _ <- result.try(validate_subscription_name(name)) - Ok(Builder( - name:, - listen_config: config, - queries_connection: connection, - query:, - start_from:, - codec:, - handler: PostgresProjectionHandler(project:), - )) -} - -/// Read a subscription's durable checkpoint and exact global event-log lag. -/// -/// `events_behind` counts all event-log rows after the cursor, including rows -/// that do not match the subscription query. -pub fn subscription_status( - connection: pog.Connection, - name name: String, -) -> Result(SubscriptionStatus, SubscriptionError) { - use _ <- result.try(validate_subscription_name(name)) - use returned <- result.try( - pog.query( - "select - subscription.cursor, - subscription.generation, - coalesce((select max(position) from factos_events), -1), - ( - select count(*) - from factos_events - where position > subscription.cursor - ) - from factos_subscriptions as subscription - where subscription.name = $1", - ) - |> pog.parameter(pog.text(name)) - |> pog.returning(subscription_status_decoder(name)) - |> pog.execute(on: connection) - |> result.map_error(SubscriptionStoreError), - ) - case returned.rows { - [] -> Error(SubscriptionNotFound(name:)) - [status, ..] -> Ok(status) - } -} - -/// Atomically reposition a durable subscription and notify its live worker. -pub fn reset_subscription( - connection: pog.Connection, - name name: String, - start_from start_from: SubscriptionStart, -) -> Result(Nil, SubscriptionError) { - use _ <- result.try(validate_subscription_name(name)) - run_subscription_transaction(connection, fn(transaction_connection) { - use _ <- result.try(load_subscription_checkpoint( - transaction_connection, - name, - )) - use event_log_position <- result.try(read_event_log_cursor( - transaction_connection, - )) - use cursor <- result.try(resolve_subscription_start( - name, - start_from, - event_log_position, - )) - use updated <- result.try( - pog.query( - "update factos_subscriptions - set cursor = $2, generation = generation + 1 - where name = $1 - returning cursor, generation", - ) - |> pog.parameter(pog.text(name)) - |> pog.parameter(pog.int(cursor_to_int(cursor))) - |> pog.returning(subscription_checkpoint_decoder()) - |> pog.execute(on: transaction_connection) - |> result.map_error(SubscriptionStoreError), - ) - case updated.rows { - [] -> Error(SubscriptionNotFound(name:)) - [SubscriptionCheckpoint(generation:, ..), ..] -> - notify_subscription_reset(transaction_connection, name, generation) - } - }) -} - -fn validate_subscription_name(name: String) -> Result(Nil, SubscriptionError) { - case string.trim(name) { - "" -> Error(InvalidSubscriptionName(name:)) - _ -> Ok(Nil) - } -} - -fn validate_subscription_names( - names: List(String), -) -> Result(Nil, SubscriptionError) { - case names { - [] -> Ok(Nil) - [name, ..remaining] -> { - use _ <- result.try(validate_subscription_name(name)) - validate_subscription_names(remaining) - } - } -} - -fn non_negative_wait_duration(timeout: duration.Duration) -> duration.Duration { - case duration.to_milliseconds(timeout) <= 0 { - True -> duration.milliseconds(0) - False -> timeout - } -} - -fn wait_for_subscription_cursors( - connection: pog.Connection, - subscriptions: List(String), - through: factos.SequencePosition, - deadline: timestamp.Timestamp, -) -> Result(Nil, SubscriptionError) { - use cursors <- result.try(read_subscription_cursors(connection, subscriptions)) - use pending_names <- result.try(pending_subscription_names( - subscriptions, - cursors, - through, - )) - case pending_names { - [] -> Ok(Nil) - [_, ..] -> { - let remaining = timestamp.difference(timestamp.system_time(), deadline) - let remaining_milliseconds = duration.to_milliseconds(remaining) - case remaining_milliseconds <= 0 { - True -> { - use statuses <- result.try(load_subscription_statuses( - connection, - pending_names, - )) - let pending = - statuses - |> list.filter(fn(status) { - let SubscriptionStatus(cursor:, ..) = status - cursor_is_after(through, cursor) - }) - case pending { - [] -> Ok(Nil) - [_, ..] -> Error(SubscriptionWaitTimedOut(through:, pending:)) - } - } - False -> { - process.sleep(int.min(remaining_milliseconds, 20)) - wait_for_subscription_cursors( - connection, - subscriptions, - through, - deadline, - ) - } - } - } - } -} - -fn read_subscription_cursors( - connection: pog.Connection, - subscriptions: List(String), -) -> Result(dict.Dict(String, factos.SequencePosition), SubscriptionError) { - use returned <- result.try( - pog.query( - "select name, cursor - from factos_subscriptions - where name = any($1::text[])", - ) - |> pog.parameter(pog.array(pog.text, subscriptions)) - |> pog.returning(subscription_cursor_decoder()) - |> pog.execute(on: connection) - |> result.map_error(SubscriptionStoreError), - ) - returned.rows - |> dict.from_list - |> Ok -} - -fn pending_subscription_names( - subscriptions: List(String), - cursors: dict.Dict(String, factos.SequencePosition), - through: factos.SequencePosition, -) -> Result(List(String), SubscriptionError) { - collect_pending_subscription_names( - subscriptions, - cursors, - through, - pending: [], - ) -} - -fn collect_pending_subscription_names( - subscriptions: List(String), - cursors: dict.Dict(String, factos.SequencePosition), - through: factos.SequencePosition, - pending pending: List(String), -) -> Result(List(String), SubscriptionError) { - case subscriptions { - [] -> Ok(list.reverse(pending)) - [name, ..remaining] -> - case dict.get(cursors, name) { - Error(Nil) -> Error(SubscriptionNotFound(name:)) - Ok(cursor) -> { - let pending = case cursor_is_after(through, cursor) { - True -> [name, ..pending] - False -> pending - } - collect_pending_subscription_names( - remaining, - cursors, - through, - pending:, - ) - } - } - } -} - -fn load_subscription_statuses( - connection: pog.Connection, - subscriptions: List(String), -) -> Result(List(SubscriptionStatus), SubscriptionError) { - use returned <- result.try( - pog.query( - "select - subscription.name, - subscription.cursor, - subscription.generation, - coalesce((select max(position) from factos_events), -1), - ( - select count(*) - from factos_events - where position > subscription.cursor - ) - from factos_subscriptions as subscription - where subscription.name = any($1::text[])", - ) - |> pog.parameter(pog.array(pog.text, subscriptions)) - |> pog.returning(named_subscription_status_decoder()) - |> pog.execute(on: connection) - |> result.map_error(SubscriptionStoreError), - ) - let statuses = - returned.rows - |> list.map(fn(status) { - let SubscriptionStatus(name:, ..) = status - #(name, status) - }) - |> dict.from_list - order_subscription_statuses(subscriptions, statuses, ordered: []) -} - -fn order_subscription_statuses( - subscriptions: List(String), - statuses: dict.Dict(String, SubscriptionStatus), - ordered ordered: List(SubscriptionStatus), -) -> Result(List(SubscriptionStatus), SubscriptionError) { - case subscriptions { - [] -> Ok(list.reverse(ordered)) - [name, ..remaining] -> - case dict.get(statuses, name) { - Error(Nil) -> Error(SubscriptionNotFound(name:)) - Ok(status) -> - order_subscription_statuses(remaining, statuses, ordered: [ - status, - ..ordered - ]) - } - } -} - -fn subscription_cursor_decoder() -> decode.Decoder( - #(String, factos.SequencePosition), -) { - use name <- decode.field(0, decode.string) - use cursor <- decode.field(1, decode.int) - decode.success(#(name, cursor_from_int(cursor))) -} - -fn named_subscription_status_decoder() -> decode.Decoder(SubscriptionStatus) { - use name <- decode.field(0, decode.string) - use cursor <- decode.field(1, decode.int) - use generation <- decode.field(2, decode.int) - use event_log_position <- decode.field(3, decode.int) - use events_behind <- decode.field(4, decode.int) - decode.success(SubscriptionStatus( - name:, - cursor: cursor_from_int(cursor), - generation:, - event_log_position: cursor_from_int(event_log_position), - events_behind:, - )) -} - -fn run_subscription_transaction( - connection: pog.Connection, - work: fn(pog.Connection) -> Result(value, SubscriptionError), -) -> Result(value, SubscriptionError) { - case pog.transaction(connection, work) { - Ok(value) -> Ok(value) - Error(pog.TransactionQueryError(error)) -> - Error(SubscriptionStoreError(error:)) - Error(pog.TransactionRolledBack(error)) -> Error(error) - } -} - -fn notify_subscription_reset( - connection: pog.Connection, - name: String, - generation: Int, -) -> Result(Nil, SubscriptionError) { - pog.query( - "with notified as materialized ( - select pg_catalog.pg_notify( - 'factos_pog_subscription_resets', - jsonb_build_object( - 'name', $1::text, - 'generation', $2::bigint - )::text - ) - ) - select 1 - from notified", - ) - |> pog.parameter(pog.text(name)) - |> pog.parameter(pog.int(generation)) - |> pog.returning(int_field_decoder()) - |> pog.execute(on: connection) - |> result.map(fn(_) { Nil }) - |> result.map_error(SubscriptionStoreError) -} - -/// Start a subscription's local supervision tree. -/// -/// The tree starts the dedicated Pog notification session before the -/// subscription actor. A subscription failure reloads its durable checkpoint -/// and retries; a notification-session failure restarts both children. -/// -/// Stop the returned handle before shutting down either database connection. -pub fn start( - subscription: Subscription(event, processing_error), -) -> actor.StartResult(SubscriptionHandle) { - use started <- result.map( - subscription_supervisor(subscription) |> static_supervisor.start, - ) - actor.Started(..started, data: SubscriptionHandle(pid: started.pid)) -} - -/// Stop a subscription and wait for its notification session to terminate. -/// -/// Stopping is idempotent. A handle whose supervision tree has already exited -/// returns successfully. -pub fn stop( - subscription: SubscriptionHandle, -) -> Result(Nil, SubscriptionStopError) { - let SubscriptionHandle(pid:) = subscription - process.unlink(pid) - case process.is_alive(pid) { - False -> Ok(Nil) - True -> { - let monitor = process.monitor(pid) - process.send_exit(pid) - let stopped = - process.new_selector() - |> process.select_specific_monitor(monitor, nil_constant) - |> process.selector_receive(5000) - process.demonitor_process(monitor) - case stopped { - Ok(Nil) -> Ok(Nil) - Error(Nil) -> Error(SubscriptionStopTimedOut) - } - } - } -} - -/// Create a permanent child specification for an application supervisor. -pub fn supervised( - subscription: Subscription(event, processing_error), -) -> supervision.ChildSpecification(Nil) { - subscription_supervisor(subscription) - |> static_supervisor.supervised - |> supervision.map_data(nil_constant) -} - -fn subscription_supervisor( - subscription: Subscription(event, processing_error), -) -> static_supervisor.Builder { - let notifications = - pog.notifications_supervised(subscription.listen_config) - |> supervision.timeout(ms: 1000) - - static_supervisor.new(strategy: static_supervisor.RestForOne) - |> static_supervisor.add(notifications) - |> static_supervisor.add( - supervision.worker(fn() { start_subscription_actor(subscription) }) - |> supervision.restart(supervision.Transient), - ) -} - -fn start_subscription_actor( - subscription: Subscription(event, processing_error), -) -> actor.StartResult(Nil) { - actor.new_with_initialiser(5000, fn(subject) { - initialise_subscription(subscription, subject) - }) - |> actor.on_message(handle_subscription_message) - |> actor.start -} - -/// Create a new codec. -/// -/// `encode` prepares a domain event for storage. `decode` selects the JSON -/// decoder for a stored descriptor. Return `UnknownEvent` for unsupported -/// type/version combinations; malformed payloads become `InvalidData`. -/// -/// WARNING: codecs used by dispatch must be pure. Serializable dispatch may -/// retry and call either function more than once for the same logical operation. -pub fn codec( - encode encode: fn(event) -> Proposed, - decode decode: fn(factos.EventDescriptor) -> - Result(decode.Decoder(event), DecodeError), -) -> EventCodec(event) { - EventCodec(encode:, decode:) -} - -@internal -pub fn read( - connection: pog.Connection, - query query: factos.Query, - decider decider: factos.Decider(command, state, event, domain_error), - codec codec: EventCodec(event), -) -> Result(factos.Context(event, state), Error(domain_error)) { - let factos.Decider(initial, _, evolve) = decider - - use events <- result.try(read_matching_events(connection, query, codec)) - let position = highest_recorded_position(events) - - Ok(factos.Context( - query:, - state: factos.evolve_recorded( - initial: initial, - events: events, - evolve: evolve, - ), - events: events, - position: position, - append_condition: factos.FailIfEventsMatch(query, position), - )) -} - -@internal -pub fn read_after( - connection: pog.Connection, - query query: factos.Query, - after after: factos.SequencePosition, - limit limit: Int, - codec codec: EventCodec(event), -) -> Result(List(factos.Recorded(event)), Error(domain_error)) { - case limit <= 0 { - True -> Ok([]) - False -> { - let QuerySql(where_sql, parameters) = - matching_events_after_sql_from(query, after, parameter_index: 1) - let limit_parameter = "$" <> int.to_string(list.length(parameters) + 1) - use rows <- result.try( - pog.query( - "select position, id, stream, revision, type, version, tags, metadata, data::text - from factos_events - " - <> where_sql - <> " - order by position - limit " - <> limit_parameter, - ) - |> with_parameters(parameters) - |> pog.parameter(pog.int(limit)) - |> pog.returning(stored_row_decoder()) - |> pog.execute(on: connection) - |> result.map(fn(returned) { returned.rows }) - |> result.map_error(StoreError), - ) - decode_stored_rows(rows, codec) - } - } -} - -fn initialise_subscription( - subscription: Subscription(event, processing_error), - subject: process.Subject(SubscriptionMessage), -) -> Result( - actor.Initialised( - SubscriptionState(event, processing_error), - SubscriptionMessage, - Nil, - ), - String, -) { - let notifications = - pog.named_notifications_connection(subscription.listen_config.pool_name) - use notifications_pid <- result.try( - process.named(subscription.listen_config.pool_name) - |> result.map_error(fn(_) { - "subscription notification listener is not registered" - }), - ) - process.trap_exits(True) - let monitor = process.monitor(notifications_pid) - use event_reference <- result.try( - listen(notifications) - |> result.map_error(fn(_) { - process.demonitor_process(monitor) - "subscription notification listener could not LISTEN" - }), - ) - use reset_reference <- result.try( - listen_on_channel(notifications, subscription_reset_channel) - |> result.map_error(fn(_) { - unlisten(notifications, event_reference) - process.demonitor_process(monitor) - "subscription reset listener could not LISTEN" - }), - ) - use checkpoint <- result.try( - initialise_subscription_checkpoint( - subscription.queries_connection, - subscription.name, - subscription.start_from, - ) - |> result.map_error(fn(error) { - unlisten(notifications, event_reference) - unlisten(notifications, reset_reference) - process.demonitor_process(monitor) - string.inspect(error) - }), - ) - let selector = - process.new_selector() - |> process.select(subject) - |> pog.select_notifications(SubscriptionNotification) - |> process.select_specific_monitor(monitor, SubscriptionListenerDown) - |> process.select_trapped_exits(SubscriptionParentExit) - - process.send(subject, CheckSubscription) - SubscriptionState( - subscription:, - subject:, - checkpoint:, - notifications_pid:, - event_reference:, - reset_reference:, - monitor:, - timer: option.None, - ) - |> actor.initialised - |> actor.selecting(selector) - |> Ok -} - -fn handle_subscription_message( - state: SubscriptionState(event, processing_error), - message: SubscriptionMessage, -) -> actor.Next(SubscriptionState(event, processing_error), SubscriptionMessage) { - case message { - CheckSubscription -> run_subscription_cycle(state) - SubscriptionNotification(notification:) -> - case route_subscription_notification(state, notification) { - Ok(EventNotificationPayload(payload:)) -> - handle_subscription_notification(state, payload) - Ok(ResetNotificationPayload(payload:)) -> - handle_subscription_reset_notification(state, payload) - Error(Nil) -> actor.continue(state) - } - SubscriptionParentExit(message: _) -> { - stop_notifications(state.notifications_pid) - process.trap_exits(False) - process.send_abnormal_exit(process.self(), atom.create("shutdown")) - actor.continue(state) - } - SubscriptionListenerDown(down:) -> - stop_subscription_abnormally( - "subscription notification listener stopped: " - <> subscription_listener_down_reason(down), - ) - } -} - -fn stop_subscription_abnormally( - reason: String, -) -> actor.Next(state, SubscriptionMessage) { - process.trap_exits(False) - actor.stop_abnormal(reason) -} - -fn run_subscription_cycle( - state: SubscriptionState(event, processing_error), -) -> actor.Next(SubscriptionState(event, processing_error), SubscriptionMessage) { - cancel_subscription_timer(state.timer) - case - load_subscription_checkpoint( - state.subscription.queries_connection, - state.subscription.name, - ) - { - Error(error) -> stop_subscription_abnormally(string.inspect(error)) - Ok(checkpoint) -> - case catch_up_subscription(state.subscription, checkpoint) { - Error(reason) -> stop_subscription_abnormally(reason) - Ok(checkpoint) -> { - let timer = - process.send_after( - state.subject, - subscription_reconciliation_interval_milliseconds, - CheckSubscription, - ) - actor.continue( - SubscriptionState(..state, checkpoint:, timer: option.Some(timer)), - ) - } - } - } -} - -fn cancel_subscription_timer(timer: option.Option(process.Timer)) -> Nil { - case timer { - option.None -> Nil - option.Some(timer) -> { - process.cancel_timer(timer) - Nil - } - } -} - -fn drain_subscription( - configuration: Subscription(event, processing_error), - checkpoint: SubscriptionCheckpoint, - through: factos.SequencePosition, -) -> Result(SubscriptionCheckpoint, String) { - let SubscriptionCheckpoint(cursor:, ..) = checkpoint - case cursor_is_after(through, cursor) { - False -> Ok(checkpoint) - True -> - case - read_events_through( - configuration.queries_connection, - query: configuration.query, - after: cursor, - through:, - limit: subscription_batch_size, - codec: configuration.codec, - ) - { - Error(error) -> - Error( - "subscription event read failed: " - <> error_to_string(error, fn(_) { - "unexpected subscription domain error" - }), - ) - Ok([]) -> - store_subscription_checkpoint( - configuration.queries_connection, - configuration.name, - checkpoint, - through, - ) - |> result.map_error(subscription_checkpoint_write_error_to_string) - Ok(events) -> { - use next_checkpoint <- result.try(handle_recorded_events( - configuration, - events, - checkpoint, - )) - drain_subscription(configuration, next_checkpoint, through) - } - } - } -} - -fn route_subscription_notification( - state: SubscriptionState(event, processing_error), - notification: pog.Notification, -) -> Result(RoutedSubscriptionNotification, Nil) { - let pog.Notify(pid:, reference:, channel:, payload:) = notification - case pid == state.notifications_pid { - False -> Error(Nil) - True -> - case - reference == state.event_reference - && channel == event_notification_channel - { - True -> Ok(EventNotificationPayload(payload:)) - False -> - case - reference == state.reset_reference - && channel == subscription_reset_channel - { - True -> Ok(ResetNotificationPayload(payload:)) - False -> Error(Nil) - } - } - } -} - -fn handle_subscription_reset_notification( - state: SubscriptionState(event, processing_error), - payload: String, -) -> actor.Next(SubscriptionState(event, processing_error), SubscriptionMessage) { - case json.parse(payload, using: reset_notification_decoder()) { - Error(_) -> actor.continue(state) - Ok(ResetNotification(name:, generation:)) -> { - let SubscriptionCheckpoint(generation: held_generation, ..) = - state.checkpoint - case name == state.subscription.name && generation > held_generation { - True -> run_subscription_cycle(state) - False -> actor.continue(state) - } - } - } -} - -fn handle_subscription_notification( - state: SubscriptionState(event, processing_error), - payload: String, -) -> actor.Next(SubscriptionState(event, processing_error), SubscriptionMessage) { - let SubscriptionCheckpoint(cursor: held_cursor, ..) = state.checkpoint - case json.parse(payload, using: notification_routing_decoder()) { - Error(_) -> run_subscription_cycle(state) - Ok(NotificationRouting(cursor:, previous_cursor:, type_:, tags:)) -> - case - cursor_is_after(cursor, held_cursor), - previous_cursor == held_cursor - { - False, _ -> actor.continue(state) - True, False -> run_subscription_cycle(state) - True, True -> { - let descriptor = - factos.EventDescriptor( - type_:, - version: 0, - tags:, - metadata: factos.empty_metadata(), - ) - case factos.matches_descriptor(descriptor, state.subscription.query) { - False -> checkpoint_inline_cursor(state, cursor) - True -> - case - decode_inline_notification(payload, state.subscription.codec) - { - Error(_) -> run_subscription_cycle(state) - Ok(InlineNotification( - cursor: inline_cursor, - previous_cursor: inline_previous_cursor, - event:, - )) -> - case - inline_cursor == cursor - && inline_previous_cursor == previous_cursor - { - False -> run_subscription_cycle(state) - True -> handle_inline_event(state, event, cursor) - } - } - } - } - } - } -} - -fn handle_inline_event( - state: SubscriptionState(event, processing_error), - recorded: factos.Recorded(event), - _cursor: factos.SequencePosition, -) -> actor.Next(SubscriptionState(event, processing_error), SubscriptionMessage) { - case - process_subscription_event(state.subscription, state.checkpoint, recorded) - { - Error(reason) -> stop_subscription_abnormally(reason) - Ok(checkpoint) -> actor.continue(SubscriptionState(..state, checkpoint:)) + subscriptions:, + ) } -} -fn checkpoint_inline_cursor( - state: SubscriptionState(event, processing_error), - cursor: factos.SequencePosition, -) -> actor.Next(SubscriptionState(event, processing_error), SubscriptionMessage) { - case - store_subscription_checkpoint( - state.subscription.queries_connection, - state.subscription.name, - state.checkpoint, - cursor, - ) - { - Error(error) -> - stop_subscription_abnormally( - subscription_checkpoint_write_error_to_string(error), + case result { + Error(error) -> Error(error) + Ok(dispatch) -> { + enqueue_fire_and_forget_subscriptions( + connection, + subscriptions, + dispatch.events, ) - Ok(checkpoint) -> actor.continue(SubscriptionState(..state, checkpoint:)) + Ok(dispatch) + } } } -fn catch_up_subscription( - configuration: Subscription(event, processing_error), - checkpoint: SubscriptionCheckpoint, -) -> Result(SubscriptionCheckpoint, String) { - use through <- result.try( - read_event_log_cursor(configuration.queries_connection) - |> result.map_error(string.inspect), - ) - drain_subscription(configuration, checkpoint, through) -} +pub type Error(domain_error, subscription_error) { + /// The decider rejected the command with a domain error. + DomainError(domain_error) -fn handle_recorded_events( - configuration: Subscription(event, processing_error), - events: List(factos.Recorded(event)), - checkpoint: SubscriptionCheckpoint, -) -> Result(SubscriptionCheckpoint, String) { - case events { - [] -> Ok(checkpoint) - [recorded, ..rest] -> { - use checkpoint <- result.try(process_subscription_event( - configuration, - checkpoint, - recorded, - )) - handle_recorded_events(configuration, rest, checkpoint) - } - } -} + /// A strong subscription callback rejected an event. + SubscriptionError(error: subscription_error) -fn process_subscription_event( - configuration: Subscription(event, processing_error), - checkpoint: SubscriptionCheckpoint, - recorded: factos.Recorded(event), -) -> Result(SubscriptionCheckpoint, String) { - let factos.Recorded(position:, ..) = recorded - case configuration.handler { - OrdinarySubscriptionHandler(handle:) -> { - use _ <- result.try( - handle(recorded) - |> result.map_error(fn(error) { - "subscription event handler failed: " <> string.inspect(error) - }), - ) - store_subscription_checkpoint( - configuration.queries_connection, - configuration.name, - checkpoint, - position, - ) - |> result.map_error(subscription_checkpoint_write_error_to_string) - } - PostgresProjectionHandler(project:) -> - process_projection_event(configuration, checkpoint, recorded, project) - } + /// PostgreSQL or `pog` returned an error while running a query. + StoreError(pog.QueryError) + + /// A stream revision or context append condition failed. + AppendConditionFailed(factos.AppendCondition) + DecodeError(DecodeError) } -fn process_projection_event( - configuration: Subscription(event, processing_error), - checkpoint: SubscriptionCheckpoint, - recorded: factos.Recorded(event), - project: fn(pog.Connection, factos.Recorded(event)) -> - Result(Nil, processing_error), -) -> Result(SubscriptionCheckpoint, String) { - let factos.Recorded(position:, ..) = recorded - case - pog.transaction( - configuration.queries_connection, - fn(transaction_connection) { - use _ <- result.try( - project(transaction_connection, recorded) - |> result.map_error(fn(error) { ProjectionCallbackFailed(error:) }), - ) - store_subscription_checkpoint( - transaction_connection, - configuration.name, - checkpoint, - position, - ) - |> result.map_error(fn(error) { ProjectionCheckpointFailed(error:) }) - }, - ) - { - Ok(checkpoint) -> Ok(checkpoint) - Error(pog.TransactionQueryError(error)) -> - Error( - "subscription projection transaction failed: " - <> query_error_to_string(error), - ) - Error(pog.TransactionRolledBack(ProjectionCallbackFailed(error:))) -> - Error("subscription projection failed: " <> string.inspect(error)) - Error(pog.TransactionRolledBack(ProjectionCheckpointFailed(error:))) -> - Error(subscription_checkpoint_write_error_to_string(error)) - } +pub type DecodeError { + UnknownEvent + InvalidData } -fn initialise_subscription_checkpoint( - connection: pog.Connection, - name: String, - start_from: SubscriptionStart, -) -> Result(SubscriptionCheckpoint, SubscriptionError) { - use existing <- result.try(load_optional_subscription_checkpoint( - connection, - name, - )) - case existing { - option.Some(checkpoint) -> Ok(checkpoint) - option.None -> { - use event_log_position <- result.try(read_event_log_cursor(connection)) - use cursor <- result.try(resolve_subscription_start( - name, - start_from, - event_log_position, - )) - use inserted <- result.try( - pog.query( - "insert into factos_subscriptions (name, cursor, generation) - values ($1, $2, 0) - on conflict (name) do nothing - returning cursor, generation", - ) - |> pog.parameter(pog.text(name)) - |> pog.parameter(pog.int(cursor_to_int(cursor))) - |> pog.returning(subscription_checkpoint_decoder()) - |> pog.execute(on: connection) - |> result.map_error(SubscriptionStoreError), - ) - case inserted.rows { - [checkpoint, ..] -> Ok(checkpoint) - [] -> load_subscription_checkpoint(connection, name) - } - } - } +type QuerySql { + QuerySql(sql: String, parameters: List(QueryParameter)) } -fn resolve_subscription_start( - name: String, - start_from: SubscriptionStart, - event_log_position: factos.SequencePosition, -) -> Result(factos.SequencePosition, SubscriptionError) { - case start_from { - Origin -> Ok(factos.NoPosition) - Current -> Ok(event_log_position) - After(position:) -> - case position { - factos.NoPosition -> Ok(factos.NoPosition) - factos.SequencePosition(value) -> - case value < 0, cursor_is_after(position, event_log_position) { - True, _ -> Error(InvalidSubscriptionPosition(position:)) - False, True -> - Error(SubscriptionStartAfterEventLog( - name:, - requested: position, - event_log_position:, - )) - False, False -> Ok(position) - } - } - } +type QueryParameter { + IntParameter(Int) + TextParameter(String) } -fn load_subscription_checkpoint( - connection: pog.Connection, - name: String, -) -> Result(SubscriptionCheckpoint, SubscriptionError) { - use checkpoint <- result.try(load_optional_subscription_checkpoint( - connection, - name, - )) - case checkpoint { - option.Some(checkpoint) -> Ok(checkpoint) - option.None -> Error(SubscriptionNotFound(name:)) - } +/// Configure a dispatch-bound subscription. +/// +/// Strong callbacks share the dispatch transaction. Fire-and-forget callbacks +/// are scheduled as supervised temporary work only after a successful commit. +pub fn new_subscription( + query query: factos.Query, + consistency consistency: SubscriptionConsistency, + handle handle: fn(pog.Connection, factos.Recorded(event)) -> + Result(Nil, subscription_error), +) -> Subscription(event, subscription_error) { + Subscription(query:, consistency:, handle:) } -fn load_optional_subscription_checkpoint( - connection: pog.Connection, - name: String, -) -> Result(option.Option(SubscriptionCheckpoint), SubscriptionError) { - use returned <- result.try( - pog.query( - "select cursor, generation - from factos_subscriptions - where name = $1", - ) - |> pog.parameter(pog.text(name)) - |> pog.returning(subscription_checkpoint_decoder()) - |> pog.execute(on: connection) - |> result.map_error(SubscriptionStoreError), - ) - case returned.rows { - [] -> Ok(option.None) - [checkpoint, ..] -> Ok(option.Some(checkpoint)) - } +/// Install a subscription supervisor in an application supervision tree. +pub fn supervised( + name: process.Name(_), +) -> supervision.ChildSpecification(Nil) { + factory_supervisor.worker_child(start_fire_and_forget_child) + |> factory_supervisor.named(name) + |> factory_supervisor.restart_strategy(supervision.Temporary) + |> factory_supervisor.supervised + |> supervision.map_data(fn(_) { Nil }) } -fn store_subscription_checkpoint( - connection: pog.Connection, - name: String, - expected: SubscriptionCheckpoint, - cursor: factos.SequencePosition, -) -> Result(SubscriptionCheckpoint, SubscriptionCheckpointWriteError) { - let SubscriptionCheckpoint(generation:, ..) = expected - use returned <- result.try( - pog.query( - "update factos_subscriptions - set cursor = greatest(cursor, $3) - where name = $1 and generation = $2 - returning cursor, generation", - ) - |> pog.parameter(pog.text(name)) - |> pog.parameter(pog.int(generation)) - |> pog.parameter(pog.int(cursor_to_int(cursor))) - |> pog.returning(subscription_checkpoint_decoder()) - |> pog.execute(on: connection) - |> result.map_error(fn(error) { - SubscriptionCheckpointError(error: SubscriptionStoreError(error:)) - }), - ) - case returned.rows { - [checkpoint, ..] -> Ok(checkpoint) - [] -> { - use durable <- result.try( - load_optional_subscription_checkpoint(connection, name) - |> result.map_error(fn(error) { SubscriptionCheckpointError(error:) }), - ) - case durable { - option.None -> - Error(SubscriptionCheckpointError(error: SubscriptionNotFound(name:))) - option.Some(_) -> Error(SubscriptionGenerationChanged) - } - } - } +fn start_fire_and_forget_child(work: fn() -> Nil) -> actor.StartResult(Nil) { + let pid = process.spawn(work) + Ok(actor.Started(pid:, data: Nil)) } -fn subscription_checkpoint_write_error_to_string( - error: SubscriptionCheckpointWriteError, -) -> String { - case error { - SubscriptionGenerationChanged -> "subscription generation changed" - SubscriptionCheckpointError(error:) -> string.inspect(error) - } +/// Create a new codec. +/// +/// `encode` prepares a domain event for storage. `decode` selects the JSON +/// decoder for a stored descriptor. Return `UnknownEvent` for unsupported +/// type/version combinations; malformed payloads become `InvalidData`. +/// +/// WARNING: codecs used by dispatch must be pure. Serializable dispatch may +/// retry and call either function more than once for the same logical operation. +pub fn codec( + encode encode: fn(event) -> Proposed, + decode decode: fn(factos.EventDescriptor) -> + Result(decode.Decoder(event), DecodeError), +) -> EventCodec(event) { + EventCodec(encode:, decode:) } -fn read_event_log_cursor( +@internal +pub fn read( connection: pog.Connection, -) -> Result(factos.SequencePosition, SubscriptionError) { - use returned <- result.try( - pog.query("select coalesce(max(position), -1) from factos_events") - |> pog.returning(int_field_decoder()) - |> pog.execute(on: connection) - |> result.map_error(SubscriptionStoreError), - ) - case returned.rows { - [] -> Ok(factos.NoPosition) - [cursor, ..] -> Ok(cursor_from_int(cursor)) - } + query query: factos.Query, + decider decider: factos.Decider(command, state, event, domain_error), + codec codec: EventCodec(event), +) -> Result( + factos.Context(event, state), + Error(domain_error, subscription_error), +) { + let factos.Decider(initial, _, evolve) = decider + + use events <- result.try(read_matching_events(connection, query, codec)) + let position = highest_recorded_position(events) + + Ok(factos.Context( + query:, + state: factos.evolve_recorded( + initial: initial, + events: events, + evolve: evolve, + ), + events: events, + position: position, + append_condition: factos.FailIfEventsMatch(query, position), + )) } -fn read_events_through( +@internal +pub fn read_after( connection: pog.Connection, query query: factos.Query, after after: factos.SequencePosition, - through through: factos.SequencePosition, limit limit: Int, codec codec: EventCodec(event), -) -> Result(List(factos.Recorded(event)), Error(domain_error)) { - case limit <= 0 || !cursor_is_after(through, after) { +) -> Result( + List(factos.Recorded(event)), + Error(domain_error, subscription_error), +) { + case limit <= 0 { True -> Ok([]) False -> { let QuerySql(where_sql, parameters) = matching_events_after_sql_from(query, after, parameter_index: 1) - let through_parameter = "$" <> int.to_string(list.length(parameters) + 1) - let limit_parameter = "$" <> int.to_string(list.length(parameters) + 2) + let limit_parameter = "$" <> int.to_string(list.length(parameters) + 1) use rows <- result.try( pog.query( "select position, id, stream, revision, type, version, tags, metadata, data::text from factos_events " <> where_sql - <> " and position <= " - <> through_parameter <> " order by position limit " <> limit_parameter, ) |> with_parameters(parameters) - |> pog.parameter(pog.int(cursor_to_int(through))) |> pog.parameter(pog.int(limit)) |> pog.returning(stored_row_decoder()) |> pog.execute(on: connection) @@ -1665,134 +432,6 @@ fn read_events_through( } } -fn reset_notification_decoder() -> decode.Decoder(ResetNotification) { - use name <- decode.field("name", decode.string) - use generation <- decode.field("generation", decode.int) - decode.success(ResetNotification(name:, generation:)) -} - -fn notification_routing_decoder() -> decode.Decoder(NotificationRouting) { - use cursor <- decode.field("cursor", decode.int) - use previous_cursor <- decode.field("previous_cursor", decode.int) - use type_ <- decode.subfield( - ["event", "type"], - decode.string |> decode.map(factos.event_type), - ) - use tags <- decode.subfield( - ["event", "tags"], - decode.string |> decode.map(factos.tag) |> decode.list, - ) - decode.success(NotificationRouting( - cursor: cursor_from_int(cursor), - previous_cursor: cursor_from_int(previous_cursor), - type_:, - tags:, - )) -} - -fn notification_descriptor_decoder() -> decode.Decoder(NotificationDescriptor) { - use cursor <- decode.field("cursor", decode.int) - use previous_cursor <- decode.field("previous_cursor", decode.int) - use id <- decode.subfield(["event", "id"], decode.string) - use stream <- decode.subfield(["event", "stream"], decode.string) - use revision <- decode.subfield(["event", "revision"], decode.int) - use type_ <- decode.subfield( - ["event", "type"], - decode.string |> decode.map(factos.event_type), - ) - use version <- decode.subfield(["event", "version"], decode.int) - use tags <- decode.subfield( - ["event", "tags"], - decode.string |> decode.map(factos.tag) |> decode.list, - ) - use metadata <- decode.subfield( - ["event", "metadata"], - decode.dict(decode.string, decode.string), - ) - decode.success(NotificationDescriptor( - cursor: cursor_from_int(cursor), - previous_cursor: cursor_from_int(previous_cursor), - id:, - stream:, - revision:, - descriptor: factos.EventDescriptor( - type_:, - version:, - tags:, - metadata: metadata |> dict.to_list |> factos.metadata, - ), - )) -} - -fn decode_inline_notification( - payload: String, - codec: EventCodec(event), -) -> Result(InlineNotification(event), DecodeError) { - use notification <- result.try( - json.parse(payload, using: notification_descriptor_decoder()) - |> result.replace_error(InvalidData), - ) - let NotificationDescriptor( - cursor:, - previous_cursor:, - id:, - stream:, - revision:, - descriptor:, - ) = notification - let EventCodec(decode: select_decoder, ..) = codec - use event_decoder <- result.try(select_decoder(descriptor)) - use event <- result.try( - json.parse(payload, using: decode.at(["event", "data"], event_decoder)) - |> result.replace_error(InvalidData), - ) - Ok(InlineNotification( - cursor:, - previous_cursor:, - event: factos.Recorded( - id:, - stream:, - revision:, - position: cursor, - event:, - descriptor:, - ), - )) -} - -fn cursor_from_int(cursor: Int) -> factos.SequencePosition { - case cursor < 0 { - True -> factos.NoPosition - False -> factos.SequencePosition(cursor) - } -} - -fn cursor_to_int(cursor: factos.SequencePosition) -> Int { - case cursor { - factos.NoPosition -> -1 - factos.SequencePosition(cursor) -> cursor - } -} - -fn cursor_is_after( - candidate: factos.SequencePosition, - cursor: factos.SequencePosition, -) -> Bool { - cursor_to_int(candidate) > cursor_to_int(cursor) -} - -fn subscription_listener_down_reason(down: process.Down) -> String { - let reason = case down { - process.ProcessDown(reason:, ..) -> reason - process.PortDown(reason:, ..) -> reason - } - case reason { - process.Normal -> "normal" - process.Killed -> "killed" - process.Abnormal(reason:) -> string.inspect(reason) - } -} - fn dispatch_query( connection: pog.Connection, stream stream_name: String, @@ -1802,7 +441,8 @@ fn dispatch_query( command command: command, event_id event_id: fn() -> String, retry_attempts retry_attempts: Int, -) -> Result(Dispatch(event), Error(domain_error)) { + subscriptions subscriptions: List(Subscription(event, subscription_error)), +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { use transaction_connection <- run_serializable_transaction( connection, retry_attempts, @@ -1818,14 +458,20 @@ fn dispatch_query( |> result.map_error(DomainError), ) let #(context, events) = pair - append_context_events( + use dispatch <- result.try(append_context_events( transaction_connection, stream_name, events, codec, event_id, context.append_condition, - ) + )) + use _ <- result.try(run_strong_subscriptions( + transaction_connection, + subscriptions, + dispatch.events, + )) + Ok(dispatch) } /// Load and fold one stream. @@ -1838,7 +484,10 @@ pub fn load_stream( stream stream_name: String, decider decider: factos.Decider(command, state, event, domain_error), codec codec: EventCodec(event), -) -> Result(factos.LoadedStream(event, state), Error(domain_error)) { +) -> Result( + factos.LoadedStream(event, state), + Error(domain_error, subscription_error), +) { let factos.Decider(initial, _, evolve) = decider use events <- result.try(read_stream_events(connection, stream_name, codec)) @@ -1862,7 +511,8 @@ fn dispatch_stream( command command: command, event_id event_id: fn() -> String, retry_attempts retry_attempts: Int, -) -> Result(Dispatch(event), Error(domain_error)) { + subscriptions subscriptions: List(Subscription(event, subscription_error)), +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { use transaction_connection <- run_serializable_transaction( connection, retry_attempts, @@ -1878,7 +528,7 @@ fn dispatch_stream( decide(loaded.state, command) |> result.map_error(DomainError), ) - append_stream_events( + use dispatch <- result.try(append_stream_events( transaction_connection, stream_name, events, @@ -1886,14 +536,21 @@ fn dispatch_stream( loaded.revision, event_id, factos.NoAppendCondition, - ) + )) + use _ <- result.try(run_strong_subscriptions( + transaction_connection, + subscriptions, + dispatch.events, + )) + Ok(dispatch) } fn run_serializable_transaction( connection: pog.Connection, retry_attempts: Int, - work: fn(pog.Connection) -> Result(Dispatch(event), Error(domain_error)), -) -> Result(Dispatch(event), Error(domain_error)) { + work: fn(pog.Connection) -> + Result(Dispatch(event), Error(domain_error, subscription_error)), +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { run_serializable_transaction_attempt( connection, work, @@ -1903,9 +560,10 @@ fn run_serializable_transaction( fn run_serializable_transaction_attempt( connection: pog.Connection, - work: fn(pog.Connection) -> Result(Dispatch(event), Error(domain_error)), + work: fn(pog.Connection) -> + Result(Dispatch(event), Error(domain_error, subscription_error)), attempts_remaining attempts_remaining: Int, -) -> Result(Dispatch(event), Error(domain_error)) { +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { let result = run_serializable_transaction_once(connection, work) case result { Ok(dispatch) -> Ok(dispatch) @@ -1925,8 +583,9 @@ fn run_serializable_transaction_attempt( fn run_serializable_transaction_once( connection: pog.Connection, - work: fn(pog.Connection) -> Result(Dispatch(event), Error(domain_error)), -) -> Result(Dispatch(event), Error(domain_error)) { + work: fn(pog.Connection) -> + Result(Dispatch(event), Error(domain_error, subscription_error)), +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { case { use transaction_connection <- pog.transaction(connection) @@ -1942,18 +601,113 @@ fn run_serializable_transaction_once( fn set_serializable_isolation( connection: pog.Connection, -) -> Result(Nil, Error(_)) { +) -> Result(Nil, Error(domain_error, subscription_error)) { pog.query("set transaction isolation level serializable") |> pog.execute(on: connection) |> result.map(nil_constant) |> result.map_error(StoreError) } -fn retryable_transaction_error(error: Error(_)) -> Bool { +fn retryable_transaction_error( + error: Error(domain_error, subscription_error), +) -> Bool { case error { StoreError(pog.PostgresqlError(code: "40001", ..)) -> True StoreError(pog.PostgresqlError(code: "40P01", ..)) -> True - _ -> False + DomainError(_) -> False + SubscriptionError(_) -> False + StoreError(_) -> False + AppendConditionFailed(_) -> False + DecodeError(_) -> False + } +} + +fn run_strong_subscriptions( + connection: pog.Connection, + subscriptions: List(Subscription(event, subscription_error)), + events: List(factos.Recorded(event)), +) -> Result(Nil, Error(domain_error, subscription_error)) { + case subscriptions { + [] -> Ok(Nil) + [Subscription(query:, consistency:, handle:), ..remaining] -> + case consistency { + FireAndForget(name: _) -> + run_strong_subscriptions(connection, remaining, events) + StrongConsistency -> { + use _ <- result.try( + run_strong_subscription_events(connection, query, handle, events) + |> result.map_error(SubscriptionError), + ) + run_strong_subscriptions(connection, remaining, events) + } + } + } +} + +fn run_strong_subscription_events( + connection: pog.Connection, + query: factos.Query, + handle: fn(pog.Connection, factos.Recorded(event)) -> + Result(Nil, subscription_error), + events: List(factos.Recorded(event)), +) -> Result(Nil, subscription_error) { + case events { + [] -> Ok(Nil) + [recorded, ..remaining] -> + case factos.matches_descriptor(recorded.descriptor, query) { + True -> { + use _ <- result.try(handle(connection, recorded)) + run_strong_subscription_events(connection, query, handle, remaining) + } + False -> + run_strong_subscription_events(connection, query, handle, remaining) + } + } +} + +fn enqueue_fire_and_forget_subscriptions( + connection: pog.Connection, + subscriptions: List(Subscription(event, subscription_error)), + events: List(factos.Recorded(event)), +) -> Nil { + use subscription <- list.each(subscriptions) + case subscription.consistency { + StrongConsistency -> Nil + + FireAndForget(name:) -> { + let events = + list.filter(events, fn(recorded) { + factos.matches_descriptor(recorded.descriptor, subscription.query) + }) + + case events { + [] -> Nil + + events -> { + let factory = factory_supervisor.get_by_name(name) + let _ = { + use <- factory_supervisor.start_child(factory) + run_fire_and_forget_events(connection, events, subscription.handle) + } + Nil + } + } + } + } +} + +fn run_fire_and_forget_events( + connection: pog.Connection, + events: List(factos.Recorded(event)), + handle: fn(pog.Connection, factos.Recorded(event)) -> + Result(Nil, subscription_error), +) -> Nil { + case events { + [] -> Nil + [recorded, ..remaining] -> { + let _ = handle(connection, recorded) + run_fire_and_forget_events(connection, remaining, handle) + } } } @@ -1964,7 +718,7 @@ fn append_context_events( codec: EventCodec(event), event_id: fn() -> String, condition: factos.AppendCondition, -) -> Result(Dispatch(event), Error(domain_error)) { +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { use revision <- result.try( current_revision(connection, stream_name) |> result.map_error(StoreError), @@ -1988,7 +742,7 @@ fn append_stream_events( expected: factos.Revision, event_id: fn() -> String, condition: factos.AppendCondition, -) -> Result(Dispatch(event), Error(domain_error)) { +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { case events { [] -> { let append = @@ -2015,7 +769,9 @@ fn append_stream_events( } } -fn map_event_insert_error(error: pog.QueryError) -> Error(domain_error) { +fn map_event_insert_error( + error: pog.QueryError, +) -> Error(domain_error, subscription_error) { case error { pog.ConstraintViolated(constraint: "factos_events_stream_revision_key", ..) -> AppendConditionFailed(factos.NoAppendCondition) @@ -2034,7 +790,7 @@ fn insert_events( condition: factos.AppendCondition, position: factos.SequencePosition, recorded_events: List(factos.Recorded(event)), -) -> Result(Dispatch(event), Error(domain_error)) { +) -> Result(Dispatch(event), Error(domain_error, subscription_error)) { case events { [] -> { let append = Append(current_revision: revision - 1, position:) @@ -2099,7 +855,7 @@ fn insert_event_if_revision_matches( tags tags: List(factos.Tag), metadata metadata: factos.Metadata, data data: json.Json, -) -> Result(Int, Error(domain_error)) { +) -> Result(Int, Error(domain_error, subscription_error)) { let QuerySql(condition_sql, condition_parameters) = append_condition_to_having_sql(condition) use returned <- result.try( @@ -2154,7 +910,10 @@ fn read_matching_events( connection: pog.Connection, query: factos.Query, codec: EventCodec(event), -) -> Result(List(factos.Recorded(event)), Error(domain_error)) { +) -> Result( + List(factos.Recorded(event)), + Error(domain_error, subscription_error), +) { let QuerySql(where_sql, parameters) = query_to_sql(query, 1) use rows <- result.try( pog.query( @@ -2178,7 +937,10 @@ fn read_stream_events( connection: pog.Connection, stream_name: String, codec: EventCodec(event), -) -> Result(List(factos.Recorded(event)), Error(domain_error)) { +) -> Result( + List(factos.Recorded(event)), + Error(domain_error, subscription_error), +) { use rows <- result.try( pog.query( "select position, id, stream, revision, type, version, tags, metadata, data::text @@ -2218,7 +980,10 @@ fn stored_row_decoder() -> decode.Decoder(StoredRow) { fn decode_stored_rows( rows: List(StoredRow), codec: EventCodec(event), -) -> Result(List(factos.Recorded(event)), Error(domain_error)) { +) -> Result( + List(factos.Recorded(event)), + Error(domain_error, subscription_error), +) { decode_stored_rows_loop(rows, codec, []) } @@ -2226,7 +991,10 @@ fn decode_stored_rows_loop( rows: List(StoredRow), codec: EventCodec(event), decoded: List(factos.Recorded(event)), -) -> Result(List(factos.Recorded(event)), Error(domain_error)) { +) -> Result( + List(factos.Recorded(event)), + Error(domain_error, subscription_error), +) { case rows { [] -> Ok(list.reverse(decoded)) [row, ..rest] -> { @@ -2239,7 +1007,7 @@ fn decode_stored_rows_loop( fn decode_stored_row( row: StoredRow, codec: EventCodec(event), -) -> Result(factos.Recorded(event), Error(domain_error)) { +) -> Result(factos.Recorded(event), Error(domain_error, subscription_error)) { let StoredRow(position:, id:, stream:, revision:, descriptor:, data:) = row let EventCodec(decode: select_decoder, ..) = codec use event_decoder <- result.try( @@ -2294,31 +1062,6 @@ fn current_revision( } } -fn subscription_status_decoder( - name: String, -) -> decode.Decoder(SubscriptionStatus) { - use cursor <- decode.field(0, decode.int) - use generation <- decode.field(1, decode.int) - use event_log_position <- decode.field(2, decode.int) - use events_behind <- decode.field(3, decode.int) - decode.success(SubscriptionStatus( - name:, - cursor: cursor_from_int(cursor), - generation:, - event_log_position: cursor_from_int(event_log_position), - events_behind:, - )) -} - -fn subscription_checkpoint_decoder() -> decode.Decoder(SubscriptionCheckpoint) { - use cursor <- decode.field(0, decode.int) - use generation <- decode.field(1, decode.int) - decode.success(SubscriptionCheckpoint( - cursor: cursor_from_int(cursor), - generation:, - )) -} - fn int_field_decoder() -> decode.Decoder(Int) { use value <- decode.field(0, decode.int) decode.success(value) @@ -2351,7 +1094,7 @@ fn insert_event_tags( connection: pog.Connection, position: factos.SequencePosition, tags: List(factos.Tag), -) -> Result(Nil, Error(domain_error)) { +) -> Result(Nil, Error(domain_error, subscription_error)) { case position, tags { factos.NoPosition, _ -> Ok(Nil) _, [] -> Ok(Nil) @@ -2581,11 +1324,14 @@ fn metadata_to_json(metadata: factos.Metadata) -> String { } pub fn error_to_string( - error: Error(domain_error), + error: Error(domain_error, subscription_error), domain_error_to_string: fn(domain_error) -> String, + subscription_error_to_string: fn(subscription_error) -> String, ) -> String { case error { DomainError(error) -> domain_error_to_string(error) + SubscriptionError(error:) -> + "subscription error: " <> subscription_error_to_string(error) StoreError(error) -> "store error: " <> query_error_to_string(error) AppendConditionFailed(factos.NoAppendCondition) -> "append to event failed: No append condition" diff --git a/backends/factos_pog/src/factos_pog_ffi.erl b/backends/factos_pog/src/factos_pog_ffi.erl deleted file mode 100644 index ae5c79f..0000000 --- a/backends/factos_pog/src/factos_pog_ffi.erl +++ /dev/null @@ -1,26 +0,0 @@ --module(factos_pog_ffi). - --export([listen/2, stop_notifications/1]). - -%% Temporary compatibility for lpil/pog#78. pgo 0.20 can return -%% {eventually, Ref} while its dedicated notification connection is still -%% reconnecting, but the open Pog PR currently accepts only {ok, Ref}. -%% Remove this adapter and call pog:listen/2 once the PR handles both results. -listen({notifications_connection, Name}, Channel) -> - try pgo_notifications:listen(Name, Channel) of - {ok, Reference} -> {ok, Reference}; - {eventually, Reference} -> {ok, Reference}; - error -> {error, nil} - catch - exit:_ -> {error, nil} - end. - -%% pgo_notifications traps linked exits, so stop its dedicated session -%% explicitly while the local subscription supervisor shuts down. -stop_notifications(Pid) -> - try gen_statem:stop(Pid, shutdown, 1000) of - ok -> nil - catch - exit:_ -> nil - end. - diff --git a/backends/factos_pog/test/factos_pog_test.gleam b/backends/factos_pog/test/factos_pog_test.gleam index 9c9a79f..3be795d 100644 --- a/backends/factos_pog/test/factos_pog_test.gleam +++ b/backends/factos_pog/test/factos_pog_test.gleam @@ -5,24 +5,22 @@ import gleam/dynamic/decode import gleam/erlang/application import gleam/erlang/atom import gleam/erlang/process -import gleam/function import gleam/int import gleam/json import gleam/list -import gleam/option.{Some} +import gleam/option import gleam/otp/actor import gleam/otp/static_supervisor import gleam/result import gleam/string -import gleam/time/duration +import gleeunit import global_value import pog import simplifile -import unitest import youid/uuid pub fn main() -> Nil { - unitest.main() + gleeunit.main() } type SharedPostgres { @@ -44,10 +42,6 @@ type Event { UserRegistered(username: String) } -type Effect { - SendWelcome(username: String) -} - type State { Available Taken @@ -70,18 +64,6 @@ type CounterState { CounterState(total: Int) } -type ManagedSubscriptionMessage { - ManagedSubscriptionEvent(event: factos.Recorded(Event)) -} - -type BlockingSubscriptionMessage { - BlockingHandlerStarted(event: factos.Recorded(Event)) -} - -type ProjectionSubscriptionMessage { - ProjectionAttemptStarted(attempt: Int, event: factos.Recorded(Event)) -} - type ProjectionBarrierMessage { ProjectionBarrierReady( name: String, @@ -90,27 +72,20 @@ type ProjectionBarrierMessage { ) } -type DispatchWaitMessage { - DispatchWaitCompleted( - result: Result( - factos_pog.Dispatch(Event), - factos_pog.DispatchWaitError(Event, DomainError), - ), +type FireSubscriptionMessage { + FireSubscriptionStarted( + pid: process.Pid, + event: factos.Recorded(Event), + release: process.Subject(Nil), ) } -type EventNotificationPayload { - EventNotificationPayload( - cursor: Int, - previous_cursor: Int, - id: String, - stream: String, - revision: Int, - type_: String, - version: Int, - tags: List(String), - correlation_id: String, - username: String, +type SubscriptionDispatchMessage { + SubscriptionDispatchFinished( + result: Result( + factos_pog.Dispatch(Event), + factos_pog.Error(DomainError, String), + ), ) } @@ -118,18 +93,18 @@ pub fn compatibility_migration_enforces_uuidv4_identity_contract_test() { use connection <- with_test_connection reset_schema(connection) assert_uuidv4_identity_contract(connection) - assert_event_notification_objects(connection) + assert_event_store_objects(connection) } pub fn dbmate_migrations_enforce_uuidv4_identity_contract_test() { use connection <- with_test_connection reset_schema_from_dbmate(connection) assert_uuidv4_identity_contract(connection) - assert_event_notification_objects(connection) + assert_event_store_objects(connection) } pub fn dbmate_upgrade_and_v2_rollback_preserve_contract_test() { - use config, connection <- with_test_database() + use _config, connection <- with_test_database() drop_schema(connection) let assert Ok(priv_directory) = application.priv_directory("factos_pog") let v1_migration = @@ -171,58 +146,19 @@ pub fn dbmate_upgrade_and_v2_rollback_preserve_contract_test() { assert factos.metadata_get(descriptor.metadata, factos.correlation_id) == Ok("migration-correlation") assert_uuidv4_identity_contract(connection) - assert_event_notification_objects(connection) - - let assert Ok(_) = - pog.query( - " - insert into factos_subscriptions (name, cursor) - values ('existing-v2-subscription', -1) - ", - ) - |> pog.execute(on: connection) - let assert Ok(generation_result) = - pog.query( - " - select generation - from factos_subscriptions - where name = 'existing-v2-subscription' - ", - ) - |> pog.returning(int_column_decoder()) - |> pog.execute(on: connection) - assert generation_result.rows == [0] - - let listener_config = - pog.Config( - ..config, - pool_name: process.new_name(prefix: "factos_pog_test_v2_listener"), - ) - let assert Ok(actor.Started(pid: notifications_pid, data: notifications)) = - pog.start_notifications(listener_config) - let assert Ok(listener) = factos_pog.listen(notifications) - let selector = - process.new_selector() - |> pog.select_notifications(function.identity) - process.sleep(100) + assert_event_store_objects(connection) - let assert Ok(v2_dispatch) = + let assert Ok(_v2_dispatch) = factos_pog.new_dispatch( connection:, - stream: "migrated-v2-notification", + stream: "migrated-v2", decider: decider(), codec: codec(), ) |> factos_pog.dispatch( - RegisterUser(username: "v2-notification"), + RegisterUser(username: "v2"), event_id: uuid.v4_string, ) - let assert Ok(pog.Notify(payload: v2_payload, ..)) = - process.selector_receive(selector, 5000) - let _ = assert_event_notification_matches(v2_payload, v2_dispatch) - - factos_pog.unlisten(notifications, listener) - process.send_exit(notifications_pid) execute_dbmate_down(connection, v2_migration) let assert Ok(legacy_event) = @@ -260,1409 +196,425 @@ pub fn dbmate_upgrade_and_v2_rollback_preserve_contract_test() { assert removed_objects.rows == ["", "", ""] } -pub fn event_notifications_wake_durable_catch_up_test() { - use config, connection <- with_test_database() +pub fn fire_and_forget_subscription_runs_after_commit_without_blocking_test() { + use connection <- with_test_connection() reset_schema(connection) - let listener_config = - pog.Config( - ..config, - pool_name: process.new_name(prefix: "factos_pog_test_event_listener"), - ) - let assert Ok(actor.Started(pid: notifications_pid, data: notifications)) = - pog.start_notifications(listener_config) - let assert Ok(listener) = factos_pog.listen(notifications) - let selector = - process.new_selector() - |> pog.select_notifications(fn(notification) { notification }) + reset_subscription_test_state(connection) + let name = process.new_name("test") + let supervisor_pid = start_test_subscription_supervisor(name) + let strong_barriers = process.new_subject() + let fire_deliveries = process.new_subject() + let dispatch_results = process.new_subject() + + let subscriptions = [ + blocking_strong_subscription(strong_barriers, name: "strong"), + blocking_fire_subscription(name, fire_deliveries), + ] - // LISTEN first, then catch up. This closes the startup race because any - // commit after LISTEN is queued while the durable read runs. - let assert Ok([]) = - factos_pog.read_after( + let _first_dispatch_pid = + start_subscription_dispatch_worker( connection, - query: factos.AllEvents, - after: factos.NoPosition, - limit: 10, - codec: codec(), - ) - process.sleep(100) - - let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "user-renata", - decider: decider(), - codec: codec(), + stream: "fire-after-commit-first", + username: "renata", + subscriptions:, + results: dispatch_results, ) - |> factos_pog.dispatch(RegisterUser("renata"), event_id: uuid.v4_string) + let #(first_strong_event, first_strong_release) = + receive_projection_barrier(strong_barriers, expected_name: "strong") - let assert Ok(pog.Notify( - pid: notification_pid, - reference: notification_reference, - channel: "factos_pog_events", - payload:, - )) = process.selector_receive(selector, 5000) - assert notification_pid == notifications_pid - assert notification_reference == listener - let EventNotificationPayload(previous_cursor:, ..) = - assert_event_notification_matches(payload, dispatch) - assert previous_cursor == -1 - - let assert Ok(events) = - factos_pog.read_after( - connection, - query: factos.AllEvents, - after: factos.NoPosition, - limit: 10, - codec: codec(), - ) - assert events == dispatch.events - assert factos.react_all(welcome_reactor(), events) == [SendWelcome("renata")] + assert all_recorded_events(connection) == [] + assert projected_users(connection) == [] + let assert Error(Nil) = process.receive(dispatch_results, within: 100) + let assert Error(Nil) = process.receive(fire_deliveries, within: 100) - let assert [last] = list.reverse(events) - let assert Ok([]) = - factos_pog.read_after( + process.send(first_strong_release, Nil) + let assert Ok(first_dispatch) = + receive_subscription_dispatch(dispatch_results) + assert all_recorded_events(connection) == first_dispatch.events + let assert [first_recorded] = first_dispatch.events + assert first_strong_event == first_recorded + let assert [#(first_projection_id, "renata")] = projected_users(connection) + assert first_projection_id == first_recorded.id + + let FireSubscriptionStarted( + pid: first_fire_pid, + event: first_fire_event, + release: first_fire_release, + ) = receive_fire_subscription(fire_deliveries) + assert [first_fire_event] == first_dispatch.events + assert process.is_alive(first_fire_pid) + let first_fire_monitor = process.monitor(first_fire_pid) + process.send(first_fire_release, Nil) + wait_for_monitor(first_fire_monitor) + + // A returned fire callback error cannot change the already committed result. + assert all_recorded_events(connection) == first_dispatch.events + assert projected_users(connection) == [#(first_projection_id, "renata")] + + let _second_dispatch_pid = + start_subscription_dispatch_worker( connection, - query: factos.AllEvents, - after: last.position, - limit: 10, - codec: codec(), - ) - - // NOTIFY follows the transaction: rolling the event back exposes neither - // a durable event nor a wake-up edge. - let assert Error(pog.TransactionRolledBack(Nil)) = - pog.transaction(connection, fn(transaction_connection) { - let assert Ok(_) = - pog.query( - " - insert into factos_events ( - id, stream, revision, type, version, tags, metadata, data - ) - values ( - 'b3b12f1d-6d85-4f1f-9c2a-94766a34f099', - 'rolled-back', - 0, - 'UserRegistered', - 1, - '', - '{}'::jsonb, - '\"rolled-back\"'::jsonb - ) - ", - ) - |> pog.execute(on: transaction_connection) - Error(Nil) - }) - let assert Error(Nil) = process.selector_receive(selector, 200) + stream: "fire-after-commit-second", + username: "maria", + subscriptions:, + results: dispatch_results, + ) + let #(second_strong_event, second_strong_release) = + receive_projection_barrier(strong_barriers, expected_name: "strong") + process.send(second_strong_release, Nil) + let assert Ok(second_dispatch) = + receive_subscription_dispatch(dispatch_results) + let assert [second_recorded] = second_dispatch.events + assert second_strong_event == second_recorded + + let FireSubscriptionStarted( + pid: second_fire_pid, + event: second_fire_event, + release: _second_fire_release, + ) = receive_fire_subscription(fire_deliveries) + assert second_fire_event == second_recorded + assert process.is_alive(second_fire_pid) + let second_fire_monitor = process.monitor(second_fire_pid) + stop_test_supervisor(supervisor_pid) + wait_for_monitor(second_fire_monitor) - factos_pog.unlisten(notifications, listener) - Nil + assert all_recorded_events(connection) + == list.append(first_dispatch.events, second_dispatch.events) + assert list.length(projected_users(connection)) == 2 } -pub fn v2_notification_payload_falls_back_to_ordered_read_test() { - use config, connection <- with_test_database() +pub fn strong_subscription_commits_with_dispatch_test() { + use connection <- with_test_connection() reset_schema(connection) - let name = "v2-notification-fallback" - let deliveries = process.new_subject() - let subscription = - test_subscription( - connection, - config, - name, - deliveries, - handle: deliver_managed_subscription_event, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - - let assert Ok(_) = - pog.query( - "alter table factos_events disable trigger factos_pog_event_insert_notify", - ) - |> pog.execute(on: connection) - let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "v2-notification-fallback", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "v2-fallback"), - event_id: uuid.v4_string, - ) - let assert [factos.Recorded(id:, ..)] = dispatch.events - let assert Ok(_) = - pog.query( - "alter table factos_events enable trigger factos_pog_event_insert_notify", - ) - |> pog.execute(on: connection) - let assert Ok(_) = - pog.query( - " - with notified as materialized ( - select pg_catalog.pg_notify( - 'factos_pog_events', - jsonb_build_object( - 'cursor', position, - 'previous_cursor', -1, - 'event', jsonb_build_object( - 'type', type, - 'version', version, - 'tags', tags::jsonb, - 'metadata', metadata, - 'data', data - ) - )::text - ) - from factos_events - where id = $1 - ) - select 1 - from notified - ", - ) - |> pog.parameter(pog.text(id)) - |> pog.returning(int_column_decoder()) - |> pog.execute(on: connection) - - let assert Ok(ManagedSubscriptionEvent(event: recorded)) = - process.receive(deliveries, within: 10_000) - assert recorded == recorded_dispatch_event(dispatch) - let assert Ok(Nil) = factos_pog.stop(handle) - Nil -} + reset_subscription_test_state(connection) -pub fn dbmate_subscription_callback_preserves_recorded_envelope_test() { - use config, connection <- with_test_database() - reset_schema_from_dbmate(connection) - let deliveries = process.new_subject() - let subscription = - test_subscription( + let barriers = process.new_subject() + let dispatch_results = process.new_subject() + let subscription = blocking_strong_subscription(barriers, name: "strong") + let _dispatch_pid = + start_subscription_dispatch_worker( connection, - config, - "dbmate-recorded-envelope", - deliveries, - handle: deliver_managed_subscription_event, + stream: "strong-commit", + username: "renata", + subscriptions: [subscription], + results: dispatch_results, ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "dbmate-recorded-envelope", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "dbmate-envelope"), - event_id: uuid.v4_string, - ) - let assert Ok(ManagedSubscriptionEvent(event: recorded)) = - process.receive(deliveries, within: 10_000) - assert recorded == recorded_dispatch_event(dispatch) + let #(strong_event, release) = + receive_projection_barrier(barriers, expected_name: "strong") + assert all_recorded_events(connection) == [] + assert projected_users(connection) == [] + let assert Error(Nil) = process.receive(dispatch_results, within: 100) - let assert Ok(Nil) = factos_pog.stop(handle) - Nil + process.send(release, Nil) + let assert Ok(dispatch) = receive_subscription_dispatch(dispatch_results) + assert dispatch.events == [strong_event] + assert all_recorded_events(connection) == dispatch.events + let assert [#(projection_id, "renata")] = projected_users(connection) + assert projection_id == strong_event.id } -pub fn subscription_start_modes_initialize_new_rows_once_test() { - use config, connection <- with_test_database() +pub fn strong_subscription_failure_rolls_back_dispatch_test() { + use connection <- with_test_connection() reset_schema(connection) - let current_deliveries = process.new_subject() - let assert Ok(first_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "start-first", - decider: decider(), - codec: codec(), + reset_subscription_test_state(connection) + + let name = process.new_name("test") + let supervisor_pid = start_test_subscription_supervisor(name) + let fire_deliveries = process.new_subject() + let insert_projection = + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.StrongConsistency, + handle: insert_test_projection, ) - |> factos_pog.dispatch( - RegisterUser(username: "first"), - event_id: uuid.v4_string, + let fail_after_observing_projection = + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.StrongConsistency, + handle: fn(transaction_connection, recorded) { + let factos.Recorded(id:, ..) = recorded + case + projected_users(transaction_connection) + |> list.any(fn(row) { row.0 == id }) + { + True -> Error("expected strong failure") + False -> Error("earlier strong callback was not visible") + } + }, ) - let assert Ok(second_dispatch) = + let fire_subscription = + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.FireAndForget(name:), + handle: fn(_connection, recorded) { + report_fire_event(fire_deliveries, recorded) + }, + ) + + let result = factos_pog.new_dispatch( connection:, - stream: "start-second", + stream: "strong-failure", decider: decider(), codec: codec(), ) + |> factos_pog.with_subscriptions(subscriptions: [ + insert_projection, + fail_after_observing_projection, + fire_subscription, + ]) |> factos_pog.dispatch( - RegisterUser(username: "second"), + RegisterUser(username: "renata"), event_id: uuid.v4_string, ) - let current_subscription = - test_subscription_from( - connection, - config, - "start-current", - current_deliveries, - start_from: factos_pog.Current, - handle: deliver_managed_subscription_event, + let assert Error(dispatch_error) = result + let assert factos_pog.SubscriptionError(error: "expected strong failure") = + dispatch_error + assert factos_pog.error_to_string( + dispatch_error, + fn(_) { "domain" }, + fn(error) { error }, ) - let assert Ok(actor.Started(data: current_handle, ..)) = - factos_pog.start(current_subscription) - let assert Error(Nil) = process.receive(current_deliveries, within: 200) - let assert Ok(current_cursor) = - load_subscription_cursor(connection, "start-current") - assert current_cursor == second_dispatch.append.position - let assert Ok(Nil) = factos_pog.stop(current_handle) - - // Once the durable row exists, a new constructor start does not reposition it. - let existing_subscription = - test_subscription_from( + == "subscription error: expected strong failure" + assert all_recorded_events(connection) == [] + assert projected_users(connection) == [] + let assert Ok(loaded) = + factos_pog.load_stream( connection, - config, - "start-current", - current_deliveries, - start_from: factos_pog.Origin, - handle: deliver_managed_subscription_event, - ) - let assert Ok(actor.Started(data: existing_handle, ..)) = - factos_pog.start(existing_subscription) - let assert Error(Nil) = process.receive(current_deliveries, within: 200) - let assert Ok(third_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "start-third", + stream: "strong-failure", decider: decider(), codec: codec(), ) - |> factos_pog.dispatch( - RegisterUser(username: "third"), - event_id: uuid.v4_string, - ) - let assert Ok(ManagedSubscriptionEvent(event: third_event)) = - process.receive(current_deliveries, within: 10_000) - assert third_event == recorded_dispatch_event(third_dispatch) - let assert Ok(Nil) = factos_pog.stop(existing_handle) - - let after_deliveries = process.new_subject() - let after_subscription = - test_subscription_from( - connection, - config, - "start-after", - after_deliveries, - start_from: factos_pog.After(position: first_dispatch.append.position), - handle: deliver_managed_subscription_event, - ) - let assert Ok(actor.Started(data: after_handle, ..)) = - factos_pog.start(after_subscription) - let assert Ok(ManagedSubscriptionEvent(event: after_second)) = - process.receive(after_deliveries, within: 10_000) - let assert Ok(ManagedSubscriptionEvent(event: after_third)) = - process.receive(after_deliveries, within: 10_000) - assert after_second == recorded_dispatch_event(second_dispatch) - assert after_third == recorded_dispatch_event(third_dispatch) - let assert Ok(Nil) = factos_pog.stop(after_handle) - - let origin_deliveries = process.new_subject() - let origin_subscription = - test_subscription_from( - connection, - config, - "start-origin", - origin_deliveries, - start_from: factos_pog.Origin, - handle: deliver_managed_subscription_event, - ) - let assert Ok(actor.Started(data: origin_handle, ..)) = - factos_pog.start(origin_subscription) - let assert Ok(ManagedSubscriptionEvent(event: origin_first)) = - process.receive(origin_deliveries, within: 10_000) - let assert Ok(ManagedSubscriptionEvent(event: origin_second)) = - process.receive(origin_deliveries, within: 10_000) - let assert Ok(ManagedSubscriptionEvent(event: origin_third)) = - process.receive(origin_deliveries, within: 10_000) - assert origin_first == recorded_dispatch_event(first_dispatch) - assert origin_second == recorded_dispatch_event(second_dispatch) - assert origin_third == recorded_dispatch_event(third_dispatch) - let assert Ok(Nil) = factos_pog.stop(origin_handle) - - Nil + assert loaded.state == Available + assert loaded.revision == factos.NoEvents + assert loaded.events == [] + let assert Error(Nil) = process.receive(fire_deliveries, within: 200) + stop_test_supervisor(supervisor_pid) } -pub fn subscription_status_and_invalid_reset_preserve_checkpoint_test() { - use config, connection <- with_test_database() +pub fn subscriptions_filter_and_skip_empty_dispatch_test() { + use connection <- with_test_connection() reset_schema(connection) - let deliveries = process.new_subject() - let assert Ok(first_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "status-first", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "status-first"), - event_id: uuid.v4_string, - ) - let subscription = - test_subscription_from( - connection, - config, - "status-subscription", - deliveries, - start_from: factos_pog.Current, - handle: deliver_managed_subscription_event, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - let assert Ok(Nil) = factos_pog.stop(handle) - let assert Ok(second_dispatch) = + let name = process.new_name("test") + let supervisor_pid = start_test_subscription_supervisor(name) + let invocations = process.new_subject() + let nonmatching_query = username_query("maria") + let nonmatching_subscriptions = [ + factos_pog.new_subscription( + query: nonmatching_query, + consistency: factos_pog.StrongConsistency, + handle: fn(connection, recorded) { + report_subscription_invocation( + invocations, + "strong", + connection, + recorded, + ) + }, + ), + factos_pog.new_subscription( + query: nonmatching_query, + consistency: factos_pog.FireAndForget(name:), + handle: fn(connection, recorded) { + report_subscription_invocation( + invocations, + "fire", + connection, + recorded, + ) + }, + ), + ] + + let assert Ok(matching_dispatch) = factos_pog.new_dispatch( connection:, - stream: "status-second", + stream: "subscription-filter", decider: decider(), codec: codec(), ) + |> factos_pog.with_subscriptions(subscriptions: nonmatching_subscriptions) |> factos_pog.dispatch( - RegisterUser(username: "status-second"), + RegisterUser(username: "renata"), event_id: uuid.v4_string, ) - let assert Ok(third_dispatch) = + let assert [_] = matching_dispatch.events + let assert Error(Nil) = process.receive(invocations, within: 200) + + let all_event_subscriptions = [ + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.StrongConsistency, + handle: fn(connection, recorded) { + report_subscription_invocation( + invocations, + "strong", + connection, + recorded, + ) + }, + ), + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.FireAndForget(name:), + handle: fn(connection, recorded) { + report_subscription_invocation( + invocations, + "fire", + connection, + recorded, + ) + }, + ), + ] + let assert Ok(empty_dispatch) = factos_pog.new_dispatch( connection:, - stream: "status-third", - decider: decider(), + stream: "subscription-empty", + decider: empty_decider(), codec: codec(), ) + |> factos_pog.with_subscriptions(subscriptions: all_event_subscriptions) |> factos_pog.dispatch( - RegisterUser(username: "status-third"), + RegisterUser(username: "ignored"), event_id: uuid.v4_string, ) + assert empty_dispatch.events == [] + assert empty_dispatch.append.position == factos.NoPosition + let assert Error(Nil) = process.receive(invocations, within: 200) + stop_test_supervisor(supervisor_pid) +} - let assert Ok(status) = - factos_pog.subscription_status(connection, name: "status-subscription") - let assert factos_pog.SubscriptionStatus( - name: "status-subscription", - cursor: first_cursor, - generation: 0, - event_log_position: third_cursor, - events_behind: 2, - ) = status - assert first_cursor == first_dispatch.append.position - assert third_cursor == third_dispatch.append.position - - let negative = factos.SequencePosition(-2) - let assert Error(factos_pog.InvalidSubscriptionPosition(position:)) = - factos_pog.reset_subscription( - connection, - name: "status-subscription", - start_from: factos_pog.After(position: negative), - ) - assert position == negative - let assert Ok(after_negative) = - factos_pog.subscription_status(connection, name: "status-subscription") - assert after_negative == status - - let assert factos.SequencePosition(head) = third_dispatch.append.position - let ahead = factos.SequencePosition(head + 1) - let assert Error(factos_pog.SubscriptionStartAfterEventLog( - name: error_name, - requested:, - event_log_position:, - )) = - factos_pog.reset_subscription( - connection, - name: "status-subscription", - start_from: factos_pog.After(position: ahead), - ) - assert error_name == "status-subscription" - assert requested == ahead - assert event_log_position == third_dispatch.append.position - let assert Ok(after_ahead) = - factos_pog.subscription_status(connection, name: "status-subscription") - assert after_ahead == status - - let assert Error(factos_pog.SubscriptionNotFound(name: missing_name)) = - factos_pog.reset_subscription( - connection, - name: "missing-subscription", - start_from: factos_pog.Origin, - ) - assert missing_name == "missing-subscription" - let assert Error(factos_pog.InvalidSubscriptionName(name: blank_name)) = - factos_pog.subscription_status(connection, name: " ") - assert blank_name == " " +fn blocking_strong_subscription( + barriers: process.Subject(ProjectionBarrierMessage), + name name: String, +) -> factos_pog.Subscription(Event, String) { + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.StrongConsistency, + handle: fn(connection, recorded) { + use _ <- result.try(insert_test_projection(connection, recorded)) + block_projection(name, barriers, recorded) + }, + ) +} - let assert Ok(Nil) = - factos_pog.reset_subscription( - connection, - name: "status-subscription", - start_from: factos_pog.Current, - ) - let assert Ok(factos_pog.SubscriptionStatus( - cursor: reset_cursor, - generation: 1, - event_log_position: reset_head, - events_behind: 0, - .., - )) = factos_pog.subscription_status(connection, name: "status-subscription") - assert reset_cursor == third_dispatch.append.position - assert reset_head == third_dispatch.append.position - let _ = second_dispatch +fn blocking_fire_subscription( + name: process.Name(_), + deliveries: process.Subject(FireSubscriptionMessage), +) -> factos_pog.Subscription(Event, String) { + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.FireAndForget(name:), + handle: fn(_connection, recorded) { + let release = process.new_subject() + process.send( + deliveries, + FireSubscriptionStarted(pid: process.self(), event: recorded, release:), + ) + case process.receive(release, within: 10_000) { + Ok(Nil) -> Error("ignored") + Error(Nil) -> Error("fire subscription release timed out") + } + }, + ) +} - Nil +fn report_fire_event( + deliveries: process.Subject(factos.Recorded(Event)), + recorded: factos.Recorded(Event), +) -> Result(Nil, String) { + process.send(deliveries, recorded) + Ok(Nil) } -pub fn online_reset_replays_origin_after_and_current_ranges_test() { - use config, connection <- with_test_database() - reset_schema(connection) - let deliveries = process.new_subject() - let assert Ok(first_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "reset-first", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "reset-first"), - event_id: uuid.v4_string, - ) - let assert Ok(second_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "reset-second", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "reset-second"), - event_id: uuid.v4_string, - ) - let assert Ok(third_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "reset-third", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "reset-third"), - event_id: uuid.v4_string, - ) - let subscription = - test_subscription_from( - connection, - config, - "online-reset", - deliveries, - start_from: factos_pog.Current, - handle: deliver_managed_subscription_event, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - let assert Error(Nil) = process.receive(deliveries, within: 200) - - let assert Ok(Nil) = - factos_pog.reset_subscription( - connection, - name: "online-reset", - start_from: factos_pog.Origin, - ) - let assert Ok(ManagedSubscriptionEvent(event: origin_first)) = - process.receive(deliveries, within: 10_000) - let assert Ok(ManagedSubscriptionEvent(event: origin_second)) = - process.receive(deliveries, within: 10_000) - let assert Ok(ManagedSubscriptionEvent(event: origin_third)) = - process.receive(deliveries, within: 10_000) - assert origin_first == recorded_dispatch_event(first_dispatch) - assert origin_second == recorded_dispatch_event(second_dispatch) - assert origin_third == recorded_dispatch_event(third_dispatch) - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - "online-reset", - third_dispatch.append.position, - 50, - ) - let assert Ok(factos_pog.SubscriptionStatus(generation: 1, ..)) = - factos_pog.subscription_status(connection, name: "online-reset") - - let assert Ok(Nil) = - factos_pog.reset_subscription( - connection, - name: "online-reset", - start_from: factos_pog.After(position: first_dispatch.append.position), - ) - let assert Ok(ManagedSubscriptionEvent(event: after_second)) = - process.receive(deliveries, within: 10_000) - let assert Ok(ManagedSubscriptionEvent(event: after_third)) = - process.receive(deliveries, within: 10_000) - assert after_second == recorded_dispatch_event(second_dispatch) - assert after_third == recorded_dispatch_event(third_dispatch) - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - "online-reset", - third_dispatch.append.position, - 50, - ) - let assert Ok(factos_pog.SubscriptionStatus(generation: 2, ..)) = - factos_pog.subscription_status(connection, name: "online-reset") - - let assert Ok(Nil) = - factos_pog.reset_subscription( - connection, - name: "online-reset", - start_from: factos_pog.Current, - ) - let assert Error(Nil) = process.receive(deliveries, within: 200) - let assert Ok(factos_pog.SubscriptionStatus( - cursor: current_cursor, - generation: 3, - events_behind: 0, - .., - )) = factos_pog.subscription_status(connection, name: "online-reset") - assert current_cursor == third_dispatch.append.position - - let assert Ok(fourth_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "reset-fourth", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "reset-fourth"), - event_id: uuid.v4_string, - ) - let assert Ok(ManagedSubscriptionEvent(event: fourth_event)) = - process.receive(deliveries, within: 10_000) - assert fourth_event == recorded_dispatch_event(fourth_dispatch) - let assert Ok(Nil) = factos_pog.stop(handle) - - Nil -} - -pub fn stale_generation_checkpoint_cannot_overwrite_online_reset_test() { - use config, connection <- with_test_database() - reset_schema(connection) - let handler_started = process.new_subject() - let assert Ok(subscription) = - factos_pog.new_subscription( - connection:, - config: pog.Config( - ..config, - pool_name: process.new_name(prefix: "stale-generation-pool"), - ), - name: "stale-generation", - query: factos.AllEvents, - start_from: factos_pog.Current, - codec: codec(), - handle: fn(recorded) { - process.send(handler_started, BlockingHandlerStarted(event: recorded)) - process.sleep(500) - Ok(Nil) - }, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "stale-generation", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "stale-generation"), - event_id: uuid.v4_string, - ) - let assert Ok(BlockingHandlerStarted(event: first_attempt)) = - process.receive(handler_started, within: 10_000) - assert first_attempt == recorded_dispatch_event(dispatch) - - let assert Ok(Nil) = - factos_pog.reset_subscription( - connection, - name: "stale-generation", - start_from: factos_pog.Origin, - ) - let assert Ok(BlockingHandlerStarted(event: second_attempt)) = - process.receive(handler_started, within: 10_000) - assert second_attempt == first_attempt - - // The stale generation completed its callback but could not advance the row. - let assert Ok(factos_pog.SubscriptionStatus( - cursor: factos.NoPosition, - generation: 1, - events_behind: 1, - .., - )) = factos_pog.subscription_status(connection, name: "stale-generation") - - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - "stale-generation", - dispatch.append.position, - 50, - ) - let assert Ok(factos_pog.SubscriptionStatus( - cursor: final_cursor, - generation: 1, - events_behind: 0, - .., - )) = factos_pog.subscription_status(connection, name: "stale-generation") - assert final_cursor == dispatch.append.position - let assert Ok(Nil) = factos_pog.stop(handle) - - Nil -} - -pub fn projection_subscription_rolls_back_failed_attempt_and_checkpoint_test() { - use config, connection <- with_test_database() - reset_schema(connection) - reset_managed_subscription_state(connection) - let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "atomic-projection", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "atomic-projection"), - event_id: uuid.v4_string, - ) - let assert Ok(subscription) = - factos_pog.new_projection_subscription( - connection:, - config: pog.Config( - ..config, - pool_name: process.new_name(prefix: "atomic-projection-pool"), - ), - name: "atomic-projection", - query: factos.AllEvents, - start_from: factos_pog.Origin, - codec: codec(), - project: fn(transaction_connection, recorded) { - use attempt <- result.try(increment_managed_subscription_attempts( - connection, - )) - use _ <- result.try(insert_test_projection( - transaction_connection, - recorded, - )) - case attempt { - 1 -> Error("fail the first projection attempt") - _ -> Ok(Nil) - } - }, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - "atomic-projection", - dispatch.append.position, - 50, - ) - - let factos.Recorded(id: event_id, event: UserRegistered(username:), ..) = - recorded_dispatch_event(dispatch) - assert projected_users(connection) == [#(event_id, username)] - assert managed_subscription_attempts(connection) == 2 - let assert Ok(factos_pog.SubscriptionStatus( - cursor: cursor, - generation: 0, - events_behind: 0, - .., - )) = factos_pog.subscription_status(connection, name: "atomic-projection") - assert cursor == dispatch.append.position - let assert Ok(Nil) = factos_pog.stop(handle) - - Nil -} - -pub fn projection_reset_race_rolls_back_stale_generation_test() { - use config, connection <- with_test_database() - reset_schema(connection) - reset_managed_subscription_state(connection) - let attempts = process.new_subject() - let assert Ok(subscription) = - factos_pog.new_projection_subscription( - connection:, - config: pog.Config( - ..config, - pool_name: process.new_name(prefix: "projection-reset-pool"), - ), - name: "projection-reset", - query: factos.AllEvents, - start_from: factos_pog.Current, - codec: codec(), - project: fn(transaction_connection, recorded) { - use attempt <- result.try(increment_managed_subscription_attempts( - connection, - )) - use _ <- result.try(insert_test_projection( - transaction_connection, - recorded, - )) - process.send( - attempts, - ProjectionAttemptStarted(attempt:, event: recorded), - ) - process.sleep(500) - Ok(Nil) - }, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "projection-reset", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "projection-reset"), - event_id: uuid.v4_string, - ) - let assert Ok(ProjectionAttemptStarted(attempt: 1, event: first_attempt)) = - process.receive(attempts, within: 10_000) - assert first_attempt == recorded_dispatch_event(dispatch) - - let assert Ok(Nil) = - factos_pog.reset_subscription( - connection, - name: "projection-reset", - start_from: factos_pog.Origin, - ) - let assert Ok(ProjectionAttemptStarted(attempt: 2, event: second_attempt)) = - process.receive(attempts, within: 10_000) - assert second_attempt == first_attempt - - // The first transaction was rolled back, and the retry is not visible before - // its generation-checked cursor update commits. - assert projected_users(connection) == [] - let assert Ok(factos_pog.SubscriptionStatus( - cursor: factos.NoPosition, - generation: 1, - events_behind: 1, - .., - )) = factos_pog.subscription_status(connection, name: "projection-reset") - - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - "projection-reset", - dispatch.append.position, - 50, - ) - let factos.Recorded(id: event_id, event: UserRegistered(username:), ..) = - recorded_dispatch_event(dispatch) - assert projected_users(connection) == [#(event_id, username)] - assert managed_subscription_attempts(connection) == 2 - let assert Ok(factos_pog.SubscriptionStatus( - cursor: final_cursor, - generation: 1, - events_behind: 0, - .., - )) = factos_pog.subscription_status(connection, name: "projection-reset") - assert final_cursor == dispatch.append.position - let assert Ok(Nil) = factos_pog.stop(handle) - - Nil -} - -pub fn dispatch_and_wait_returns_after_projection_commit_test() { - use config, connection <- with_test_database() - reset_schema(connection) - reset_managed_subscription_state(connection) - let barriers = process.new_subject() - let results = process.new_subject() - let assert Ok(subscription) = - factos_pog.new_projection_subscription( - connection:, - config: pog.Config( - ..config, - pool_name: process.new_name(prefix: "dispatch-wait-projection-pool"), - ), - name: "dispatch-wait-projection", - query: factos.AllEvents, - start_from: factos_pog.Current, - codec: codec(), - project: fn(transaction_connection, recorded) { - use _ <- result.try(insert_test_projection( - transaction_connection, - recorded, - )) - block_projection("dispatch-wait-projection", barriers, recorded) - }, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - let _worker = - start_dispatch_wait_worker( - connection, - stream: "dispatch-wait-projection", - username: "dispatch-wait-projection", - subscriptions: ["dispatch-wait-projection"], - timeout: duration.seconds(5), - results:, - ) - - let assert Ok(ProjectionBarrierReady(event: projected_event, release:, ..)) = - process.receive(barriers, within: 10_000) - let assert Error(Nil) = process.receive(results, within: 200) - assert projected_users(connection) == [] - - process.send(release, Nil) - let assert Ok(DispatchWaitCompleted(result: Ok(dispatch))) = - process.receive(results, within: 10_000) - assert recorded_dispatch_event(dispatch) == projected_event - let factos.Recorded(id:, event: UserRegistered(username:), ..) = - projected_event - assert projected_users(connection) == [#(id, username)] - let assert Ok(factos_pog.SubscriptionStatus( - cursor: cursor, - events_behind: 0, - .., - )) = - factos_pog.subscription_status(connection, name: "dispatch-wait-projection") - assert cursor == dispatch.append.position - let assert Ok(Nil) = factos_pog.stop(handle) - - Nil -} - -pub fn dispatch_and_wait_waits_for_every_unique_subscription_test() { - use config, connection <- with_test_database() - reset_schema(connection) - let barriers = process.new_subject() - let results = process.new_subject() - let first_subscription = - blocking_projection_subscription(connection, config, "wait-first", barriers) - let second_subscription = - blocking_projection_subscription( - connection, - config, - "wait-second", - barriers, - ) - let assert Ok(actor.Started(data: first_handle, ..)) = - factos_pog.start(first_subscription) - let assert Ok(actor.Started(data: second_handle, ..)) = - factos_pog.start(second_subscription) - let selected = ["wait-second", "wait-first", "wait-second"] - let _worker = - start_dispatch_wait_worker( - connection, - stream: "wait-every-subscription", - username: "wait-every-subscription", - subscriptions: selected, - timeout: duration.seconds(5), - results:, - ) - let assert Ok(first_ready) = process.receive(barriers, within: 10_000) - let assert Ok(second_ready) = process.receive(barriers, within: 10_000) - let ProjectionBarrierReady(event: first_event, release: first_release, ..) = - first_ready - let ProjectionBarrierReady(event: second_event, release: second_release, ..) = - second_ready - assert first_event == second_event - - let assert Error(factos_pog.SubscriptionWaitTimedOut( - through:, - pending: [first_pending, second_pending], - )) = - factos_pog.wait_for_subscriptions( - connection, - subscriptions: selected, - through: first_event.position, - timeout: duration.nanoseconds(500_000), - ) - assert through == first_event.position - let factos_pog.SubscriptionStatus(name: first_pending_name, ..) = - first_pending - let factos_pog.SubscriptionStatus(name: second_pending_name, ..) = - second_pending - assert first_pending_name == "wait-second" - assert second_pending_name == "wait-first" - let assert Error(factos_pog.SubscriptionWaitTimedOut(..)) = - factos_pog.wait_for_subscriptions( - connection, - subscriptions: selected, - through: first_event.position, - timeout: duration.milliseconds(-1), - ) - - process.send(first_release, Nil) - let assert Error(Nil) = process.receive(results, within: 200) - process.send(second_release, Nil) - let assert Ok(DispatchWaitCompleted(result: Ok(dispatch))) = - process.receive(results, within: 10_000) - assert recorded_dispatch_event(dispatch) == first_event - - let assert Ok(Nil) = factos_pog.stop(first_handle) - let assert Ok(Nil) = factos_pog.stop(second_handle) - Nil -} - -pub fn dispatch_and_wait_timeout_carries_committed_dispatch_test() { - use config, connection <- with_test_database() - reset_schema(connection) - let barriers = process.new_subject() - let subscription = - blocking_projection_subscription( - connection, - config, - "timeout-projection", - barriers, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - - let result = - factos_pog.new_dispatch( - connection:, - stream: "timeout-projection", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch_and_wait( - RegisterUser(username: "timeout-projection"), - event_id: uuid.v4_string, - subscriptions: ["timeout-projection"], - timeout: duration.milliseconds(50), - ) - let assert Error(factos_pog.DispatchCommittedButNotObserved( - dispatch:, - reason: factos_pog.SubscriptionWaitTimedOut(through:, pending: [pending]), - )) = result - assert through == dispatch.append.position - let assert factos_pog.SubscriptionStatus( - name: pending_name, - cursor: factos.NoPosition, - events_behind: 1, - .., - ) = pending - assert pending_name == "timeout-projection" - let assert Ok(events) = - factos_pog.read_after( - connection, - query: factos.AllEvents, - after: factos.NoPosition, - limit: 10, - codec: codec(), - ) - assert events == dispatch.events - - let assert Ok(ProjectionBarrierReady(release:, ..)) = - process.receive(barriers, within: 10_000) - process.send(release, Nil) - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - "timeout-projection", - dispatch.append.position, - 50, - ) - let assert Ok(Nil) = factos_pog.stop(handle) - Nil -} - -pub fn dispatch_and_wait_missing_subscription_carries_commit_test() { - use connection <- with_test_connection() - reset_schema(connection) - let result = - factos_pog.new_dispatch( - connection:, - stream: "missing-wait-subscription", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch_and_wait( - RegisterUser(username: "missing-wait-subscription"), - event_id: uuid.v4_string, - subscriptions: ["missing-subscription"], - timeout: duration.seconds(5), - ) - let assert Error(factos_pog.DispatchCommittedButNotObserved( - dispatch:, - reason: factos_pog.SubscriptionNotFound(name: missing_name), - )) = result - assert missing_name == "missing-subscription" - let assert Ok(events) = - factos_pog.read_after( - connection, - query: factos.AllEvents, - after: factos.NoPosition, - limit: 10, - codec: codec(), - ) - assert events == dispatch.events -} - -pub fn dispatch_and_wait_wraps_precommit_failure_test() { - use connection <- with_test_connection() - reset_schema(connection) - let assert Ok(first_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "precommit-failure", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "already-registered"), - event_id: uuid.v4_string, - ) - let result = - factos_pog.new_dispatch( - connection:, - stream: "precommit-failure", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch_and_wait( - RegisterUser(username: "already-registered"), - event_id: uuid.v4_string, - subscriptions: ["missing-subscription"], - timeout: duration.seconds(5), - ) - let assert Error(factos_pog.DispatchNotCommitted(error: factos_pog.DomainError( - AlreadyTaken, - ))) = result - let assert Ok(events) = - factos_pog.read_after( - connection, - query: factos.AllEvents, - after: factos.NoPosition, - limit: 10, - codec: codec(), - ) - assert events == first_dispatch.events -} - -pub fn dispatch_and_wait_bypasses_lookup_without_barrier_test() { - use connection <- with_test_connection() - reset_schema(connection) - let assert Ok(no_event_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "no-event-wait", - decider: counter_decider(), - codec: counter_codec(), - ) - |> factos_pog.dispatch_and_wait( - DoNothing, - event_id: uuid.v4_string, - subscriptions: ["missing-subscription"], - timeout: duration.seconds(5), - ) - assert no_event_dispatch.append.position == factos.NoPosition - assert no_event_dispatch.events == [] - - let assert Ok(event_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "empty-subscription-wait", - decider: counter_decider(), - codec: counter_codec(), - ) - |> factos_pog.dispatch_and_wait( - Increment, - event_id: uuid.v4_string, - subscriptions: [], - timeout: duration.seconds(5), - ) - let assert factos.SequencePosition(_) = event_dispatch.append.position - let assert [_] = event_dispatch.events - - let assert Error(factos_pog.InvalidSubscriptionName(name: blank_name)) = - factos_pog.wait_for_subscriptions( - connection, - subscriptions: [" "], - through: event_dispatch.append.position, - timeout: duration.milliseconds(0), - ) - assert blank_name == " " - let negative = factos.SequencePosition(-2) - let assert Error(factos_pog.InvalidSubscriptionPosition(position:)) = - factos_pog.wait_for_subscriptions( - connection, - subscriptions: ["missing-subscription"], - through: negative, - timeout: duration.milliseconds(0), - ) - assert position == negative - Nil +fn report_subscription_invocation( + invocations: process.Subject(String), + name: String, + _connection: pog.Connection, + _recorded: factos.Recorded(Event), +) -> Result(Nil, String) { + process.send(invocations, name) + Ok(Nil) } -pub fn invalid_initial_start_leaves_no_durable_checkpoint_test() { - use config, connection <- with_test_database() - reset_schema(connection) - let deliveries = process.new_subject() - let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "invalid-start-head", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "invalid-start-head"), - event_id: uuid.v4_string, - ) - - let negative_subscription = - test_subscription_from( - connection, - config, - "negative-initial-start", - deliveries, - start_from: factos_pog.After(position: factos.SequencePosition(-2)), - handle: deliver_managed_subscription_event, - ) - wait_for_subscription_start_failure(negative_subscription) - let assert Error(factos_pog.SubscriptionNotFound(name: negative_name)) = - factos_pog.subscription_status(connection, name: "negative-initial-start") - assert negative_name == "negative-initial-start" - - let assert factos.SequencePosition(head) = dispatch.append.position - let ahead_subscription = - test_subscription_from( - connection, - config, - "ahead-initial-start", - deliveries, - start_from: factos_pog.After(position: factos.SequencePosition(head + 1)), - handle: deliver_managed_subscription_event, - ) - wait_for_subscription_start_failure(ahead_subscription) - let assert Error(factos_pog.SubscriptionNotFound(name: ahead_name)) = - factos_pog.subscription_status(connection, name: "ahead-initial-start") - assert ahead_name == "ahead-initial-start" - - Nil +fn start_test_subscription_supervisor(name: process.Name(_)) -> process.Pid { + let assert Ok(actor.Started(pid:, ..)) = + static_supervisor.new(strategy: static_supervisor.OneForOne) + |> static_supervisor.add(factos_pog.supervised(name)) + |> static_supervisor.start + pid } -pub fn managed_subscription_starts_catches_up_and_stops_test() { - use config, connection <- with_test_database() - reset_schema(connection) - reset_managed_subscription_state(connection) - let name = "managed-users" - let deliveries = process.new_subject() - let assert Ok(first_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "managed-renata", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "renata"), - event_id: uuid.v4_string, - ) - let subscription = - test_subscription( - connection, - config, - name, - deliveries, - handle: deliver_managed_subscription_event, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - - let assert Ok(ManagedSubscriptionEvent(event: first_event)) = - process.receive(deliveries, within: 10_000) - assert first_event == recorded_dispatch_event(first_dispatch) - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - name, - first_dispatch.append.position, - 50, - ) - - let assert Ok(second_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "managed-lucy", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "lucy"), - event_id: uuid.v4_string, - ) - let assert Ok(ManagedSubscriptionEvent(event: second_event)) = - process.receive(deliveries, within: 10_000) - assert second_event == recorded_dispatch_event(second_dispatch) - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - name, - second_dispatch.append.position, - 50, - ) - - // Oversized envelopes fall back to a cursor-only notification and one - // durable recovery read instead of failing the event transaction. - let oversized_username = string.repeat("x", times: 9000) - let assert Ok(returned) = - pog.query( - " - insert into factos_events ( - id, stream, revision, type, version, tags, metadata, data - ) - values ( - 'b3b12f1d-6d85-4f1f-9c2a-94766a34f077', - 'managed-oversized', - 0, - 'UserRegistered', - 1, - '[]', - '{}'::jsonb, - $1::jsonb - ) - returning position - ", - ) - |> pog.parameter(pog.text(json.to_string(json.string(oversized_username)))) - |> pog.returning(int_column_decoder()) - |> pog.execute(on: connection) - let assert [oversized_cursor] = returned.rows - let assert Ok(ManagedSubscriptionEvent(event: factos.Recorded( - event: UserRegistered(username:), - .., - ))) = process.receive(deliveries, within: 10_000) - assert username == oversized_username - let assert Ok(Nil) = - wait_for_subscription_cursor( - connection, - name, - factos.SequencePosition(oversized_cursor), - 50, - ) - - let assert Ok(Nil) = factos_pog.stop(handle) +fn start_subscription_dispatch_worker( + connection: pog.Connection, + stream stream_name: String, + username username: String, + subscriptions subscriptions: List(factos_pog.Subscription(Event, String)), + results results: process.Subject(SubscriptionDispatchMessage), +) -> process.Pid { + process.spawn(fn() { + let result = + factos_pog.new_dispatch( + connection:, + stream: stream_name, + decider: decider(), + codec: codec(), + ) + |> factos_pog.with_subscriptions(subscriptions:) + |> factos_pog.dispatch(RegisterUser(username:), event_id: uuid.v4_string) + process.send(results, SubscriptionDispatchFinished(result:)) + }) +} - Nil +fn receive_subscription_dispatch( + results: process.Subject(SubscriptionDispatchMessage), +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError, String)) { + let assert Ok(SubscriptionDispatchFinished(result:)) = + process.receive(results, within: 10_000) + result } -pub fn managed_subscription_checkpoints_each_successful_event_test() { - use config, connection <- with_test_database() - reset_schema(connection) - reset_managed_subscription_state(connection) - let name = "managed-per-event-checkpoint" - let deliveries = process.new_subject() - let assert Ok(first_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "checkpoint-renata", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "renata"), - event_id: uuid.v4_string, - ) - let assert Ok(second_dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "checkpoint-lucy", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "lucy"), - event_id: uuid.v4_string, - ) - let assert Ok(subscription) = - factos_pog.new_subscription( - connection:, - config: pog.Config( - ..config, - pool_name: process.new_name(prefix: "listen-test-pool"), - ), - name:, - query: factos.AllEvents, - start_from: factos_pog.Origin, - codec: codec(), - handle: fn(event) { - process.send(deliveries, ManagedSubscriptionEvent(event:)) - let factos.Recorded(event: UserRegistered(username:), ..) = event - case username == "lucy" { - True -> Error("expected retry") - False -> Ok(Nil) - } - }, - ) - let assert Ok(actor.Started(data: handle, ..)) = - factos_pog.start(subscription) - - let assert Ok(ManagedSubscriptionEvent(event: first_delivery)) = - process.receive(deliveries, within: 10_000) - let assert Ok(ManagedSubscriptionEvent(event: second_delivery)) = - process.receive(deliveries, within: 10_000) - assert first_delivery == recorded_dispatch_event(first_dispatch) - assert second_delivery == recorded_dispatch_event(second_dispatch) - let assert Ok(cursor) = load_subscription_cursor(connection, name) - assert cursor == first_dispatch.append.position - - // The restarted worker begins at the first event's checkpoint, so only - // the failing second event is retried. - let assert Ok(ManagedSubscriptionEvent(event: retried_delivery)) = - process.receive(deliveries, within: 10_000) - assert retried_delivery == second_delivery - - let assert Ok(Nil) = factos_pog.stop(handle) +fn receive_projection_barrier( + barriers: process.Subject(ProjectionBarrierMessage), + expected_name expected_name: String, +) -> #(factos.Recorded(Event), process.Subject(Nil)) { + let assert Ok(ProjectionBarrierReady(name:, event:, release:)) = + process.receive(barriers, within: 10_000) + assert name == expected_name + #(event, release) +} - Nil +fn receive_fire_subscription( + deliveries: process.Subject(FireSubscriptionMessage), +) -> FireSubscriptionMessage { + let assert Ok(delivery) = process.receive(deliveries, within: 10_000) + delivery } -pub fn supervised_subscription_restarts_from_durable_cursor_test() { - use config, connection <- with_test_database() - reset_schema(connection) - reset_managed_subscription_state(connection) - let name = "supervised-users" - let deliveries = process.new_subject() - let assert Ok(dispatch) = - factos_pog.new_dispatch( - connection:, - stream: "supervised-renata", - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch( - RegisterUser(username: "renata"), - event_id: uuid.v4_string, - ) - let subscription = - test_subscription( +fn all_recorded_events( + connection: pog.Connection, +) -> List(factos.Recorded(Event)) { + let assert Ok(events) = + factos_pog.read_after( connection, - config, - name, - deliveries, - handle: retry_managed_subscription_event_once, + query: factos.AllEvents, + after: factos.NoPosition, + limit: 100, + codec: codec(), ) - let assert Ok(actor.Started(pid: supervisor_pid, ..)) = - static_supervisor.new(strategy: static_supervisor.OneForOne) - |> static_supervisor.add(factos_pog.supervised(subscription)) - |> static_supervisor.start + events +} - let assert Ok(ManagedSubscriptionEvent(event:)) = - process.receive(deliveries, within: 10_000) - assert event == recorded_dispatch_event(dispatch) +fn wait_for_monitor(monitor: process.Monitor) -> Nil { let assert Ok(Nil) = - wait_for_subscription_cursor(connection, name, dispatch.append.position, 50) - assert managed_subscription_attempts(connection) == 2 - - stop_test_supervisor(supervisor_pid) + process.new_selector() + |> process.select_specific_monitor(monitor, fn(_) { Nil }) + |> process.selector_receive(5000) + process.demonitor_process(monitor) } pub fn read_after_orders_filters_and_bounds_pages_test() { @@ -1815,50 +767,153 @@ pub fn concurrent_dispatch_same_stream_allows_one_empty_state_append_test() { pub fn concurrent_dispatch_with_query_retries_duplicate_username_test() { use connection <- with_test_connection() reset_schema(connection) + reset_subscription_test_state(connection) + install_one_commit_serialization_failure(connection) let query = username_query("renata") let messages = process.new_subject() + let projection_barriers = process.new_subject() + let fire_deliveries = process.new_subject() + let name = process.new_name("test") + let supervisor_pid = start_test_subscription_supervisor(name) + let subscriptions = [ + concurrent_strong_subscription(projection_barriers), + concurrent_fire_subscription(name, fire_deliveries), + ] start_blocked_query_dispatch_worker( connection, - messages: messages, + messages:, worker: "first", stream: "concurrent-query-renata-1", - query: query, + query:, command: RegisterUser(username: "renata"), + subscriptions:, ) start_blocked_query_dispatch_worker( connection, - messages: messages, + messages:, worker: "second", stream: "concurrent-query-renata-2", - query: query, + query:, command: RegisterUser(username: "renata"), + subscriptions:, ) let first_release = receive_dispatch_ready(messages) let second_release = receive_dispatch_ready(messages) process.send(first_release, Nil) - let first_result = receive_dispatch_finished(messages) process.send(second_release, Nil) + + // The first transaction reaches every strong callback, then a deferred test + // trigger raises SQLSTATE 40001 while committing. Its event and projection + // writes must both roll back before the backend retries it. + let assert Ok(ProjectionBarrierReady( + event: aborted_attempt, + release: aborted_projection_release, + .., + )) = process.receive(projection_barriers, within: 10_000) + assert all_recorded_events(connection) == [] + assert projected_users(connection) == [] + process.send(aborted_projection_release, Nil) + + // Once the aborted transaction releases the append lock, the competitor + // reaches the same strong callback and becomes the sole committed dispatch. + let assert Ok(ProjectionBarrierReady( + event: committed_attempt, + release: committed_projection_release, + .., + )) = process.receive(projection_barriers, within: 10_000) + assert all_recorded_events(connection) == [] + assert projected_users(connection) == [] + process.send(committed_projection_release, Nil) + + let first_result = receive_dispatch_finished(messages) let second_result = receive_dispatch_finished(messages) let results = [first_result, second_result] + let committed_dispatches = list.filter_map(results, fn(result) { result }) + let assert [committed_dispatch] = committed_dispatches assert list.count(results, where: is_successful_dispatch) == 1 assert list.count(results, where: is_already_taken_dispatch) == 1 + assert committed_dispatch.events == [committed_attempt] + assert aborted_attempt.id != committed_attempt.id + assert all_recorded_events(connection) == committed_dispatch.events + assert projected_users(connection) == [#(committed_attempt.id, "renata")] + + let assert Ok(fire_event) = process.receive(fire_deliveries, within: 10_000) + assert fire_event == committed_attempt + let assert Error(Nil) = process.receive(fire_deliveries, within: 200) + let assert Error(Nil) = process.receive(projection_barriers, within: 200) let assert Ok(context) = - factos_pog.read( - connection, - query: query, - decider: decider(), - codec: codec(), - ) + factos_pog.read(connection, query:, decider: decider(), codec: codec()) assert context.state == Taken - assert list.length(context.events) == 1 - let assert [event] = context.events - assert event.event == UserRegistered("renata") + assert context.events == [committed_attempt] + stop_test_supervisor(supervisor_pid) +} + +fn install_one_commit_serialization_failure(connection: pog.Connection) -> Nil { + let assert Ok(_) = + pog.query("create sequence factos_pog_test_commit_attempts") + |> pog.execute(on: connection) + let assert Ok(_) = + pog.query( + " + create function factos_pog_test_fail_first_commit() + returns trigger + language plpgsql + as $function$ + begin + if nextval('factos_pog_test_commit_attempts') = 1 then + raise exception 'expected serialization retry' using errcode = '40001'; + end if; + return new; + end; + $function$ + ", + ) + |> pog.execute(on: connection) + let assert Ok(_) = + pog.query( + " + create constraint trigger factos_pog_test_fail_first_commit + after insert on factos_events + deferrable initially deferred + for each row + execute function factos_pog_test_fail_first_commit() + ", + ) + |> pog.execute(on: connection) Nil } +fn concurrent_strong_subscription( + barriers: process.Subject(ProjectionBarrierMessage), +) -> factos_pog.Subscription(Event, Nil) { + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.StrongConsistency, + handle: fn(connection, recorded) { + let assert Ok(Nil) = insert_test_projection(connection, recorded) + let assert Ok(Nil) = block_projection(recorded.stream, barriers, recorded) + Ok(Nil) + }, + ) +} + +fn concurrent_fire_subscription( + name: process.Name(_), + deliveries: process.Subject(factos.Recorded(Event)), +) -> factos_pog.Subscription(Event, Nil) { + factos_pog.new_subscription( + query: factos.AllEvents, + consistency: factos_pog.FireAndForget(name:), + handle: fn(_connection, recorded) { + process.send(deliveries, recorded) + Ok(Nil) + }, + ) +} + pub fn dispatch_builder_with_query_filters_before_decoding_unknown_events_test() { use connection <- with_test_connection() reset_schema(connection) @@ -2129,7 +1184,10 @@ type DispatchMessage { DispatchReady(worker: String, release: process.Subject(Nil)) DispatchFinished( worker: String, - result: Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError)), + result: Result( + factos_pog.Dispatch(Event), + factos_pog.Error(DomainError, Nil), + ), ) } @@ -2160,18 +1218,20 @@ fn start_blocked_query_dispatch_worker( stream stream_name: String, query query: factos.Query, command command: Command, + subscriptions subscriptions: List(factos_pog.Subscription(Event, Nil)), ) -> process.Pid { process.spawn(fn() { let result = factos_pog.new_dispatch( - connection: connection, + connection:, stream: stream_name, - decider: blocking_decider(messages, worker: worker), + decider: blocking_decider(messages, worker:), codec: codec(), ) - |> factos_pog.with_query(query: query) + |> factos_pog.with_query(query:) + |> factos_pog.with_subscriptions(subscriptions:) |> factos_pog.dispatch(command, event_id: uuid.v4_string) - process.send(messages, DispatchFinished(worker: worker, result: result)) + process.send(messages, DispatchFinished(worker:, result:)) }) } @@ -2209,7 +1269,7 @@ fn receive_dispatch_ready( fn receive_dispatch_finished( messages: process.Subject(DispatchMessage), -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError, Nil)) { let assert Ok(message) = process.receive(messages, within: 10_000) case message { DispatchFinished(worker: _, result:) -> result @@ -2221,7 +1281,7 @@ fn receive_dispatch_finished( } fn is_successful_dispatch( - result: Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError)), + result: Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError, Nil)), ) -> Bool { case result { Ok(_) -> True @@ -2230,7 +1290,7 @@ fn is_successful_dispatch( } fn is_stale_stream_dispatch( - result: Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError)), + result: Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError, Nil)), ) -> Bool { case result { Error(factos_pog.AppendConditionFailed(factos.NoAppendCondition)) -> True @@ -2240,7 +1300,7 @@ fn is_stale_stream_dispatch( } fn is_already_taken_dispatch( - result: Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError)), + result: Result(factos_pog.Dispatch(Event), factos_pog.Error(DomainError, Nil)), ) -> Bool { case result { Error(factos_pog.DomainError(AlreadyTaken)) -> True @@ -2345,7 +1405,7 @@ fn start_test_connection( |> pog.port(port_number) |> pog.database(database_name) |> pog.user(username) - |> pog.password(Some(password)) + |> pog.password(option.Some(password)) |> pog.ssl(pog.SslDisabled) let assert Ok(actor.Started(pid:, ..)) = pog.start(config) @@ -2377,9 +1437,6 @@ fn drop_schema(connection: pog.Connection) -> Nil { let assert Ok(_) = pog.query("drop function if exists factos_pog_notify_outbox_available()") |> pog.execute(on: connection) - let assert Ok(_) = - pog.query("drop table if exists factos_subscriptions") - |> pog.execute(on: connection) let assert Ok(_) = pog.query("drop table if exists factos_event_tags") |> pog.execute(on: connection) @@ -2395,45 +1452,7 @@ fn drop_schema(connection: pog.Connection) -> Nil { Nil } -fn wait_for_subscription_start_failure( - subscription: factos_pog.Subscription(Event, String), -) -> Nil { - let subscription_process = - process.spawn_unlinked(fn() { - let _ = factos_pog.start(subscription) - Nil - }) - let monitor = process.monitor(subscription_process) - let assert Ok(Nil) = - process.new_selector() - |> process.select_specific_monitor(monitor, fn(_) { Nil }) - |> process.selector_receive(5000) - process.demonitor_process(monitor) - Nil -} - -fn reset_managed_subscription_state(connection: pog.Connection) -> Nil { - let assert Ok(_) = - pog.query("drop table if exists factos_pog_test_subscription") - |> pog.execute(on: connection) - let assert Ok(_) = - pog.query( - " - create table factos_pog_test_subscription ( - singleton boolean primary key check (singleton), - attempts integer not null - ) - ", - ) - |> pog.execute(on: connection) - let assert Ok(_) = - pog.query( - " - insert into factos_pog_test_subscription (singleton, attempts) - values (true, 0) - ", - ) - |> pog.execute(on: connection) +fn reset_subscription_test_state(connection: pog.Connection) -> Nil { let assert Ok(_) = pog.query("drop table if exists factos_pog_test_projection") |> pog.execute(on: connection) @@ -2450,166 +1469,6 @@ fn reset_managed_subscription_state(connection: pog.Connection) -> Nil { Nil } -fn test_subscription( - connection: pog.Connection, - config: pog.Config, - name: String, - deliveries: process.Subject(ManagedSubscriptionMessage), - handle handle: fn( - pog.Connection, - process.Subject(ManagedSubscriptionMessage), - factos.Recorded(Event), - ) -> Result(Nil, String), -) -> factos_pog.Subscription(Event, String) { - test_subscription_from( - connection, - config, - name, - deliveries, - start_from: factos_pog.Origin, - handle:, - ) -} - -fn test_subscription_from( - connection: pog.Connection, - config: pog.Config, - name: String, - deliveries: process.Subject(ManagedSubscriptionMessage), - start_from start_from: factos_pog.SubscriptionStart, - handle handle: fn( - pog.Connection, - process.Subject(ManagedSubscriptionMessage), - factos.Recorded(Event), - ) -> Result(Nil, String), -) -> factos_pog.Subscription(Event, String) { - let assert Ok(subscription) = - factos_pog.new_subscription( - connection:, - config: pog.Config( - ..config, - pool_name: process.new_name(prefix: "listen-test-pool"), - ), - name:, - query: factos.AllEvents, - start_from:, - codec: codec(), - handle: fn(event) { handle(connection, deliveries, event) }, - ) - subscription -} - -fn recorded_dispatch_event( - dispatch: factos_pog.Dispatch(Event), -) -> factos.Recorded(Event) { - let assert [recorded] = dispatch.events - recorded -} - -fn load_subscription_cursor( - connection: pog.Connection, - name: String, -) -> Result(factos.SequencePosition, String) { - case - pog.query( - " - select cursor - from factos_subscriptions - where name = $1 - ", - ) - |> pog.parameter(pog.text(name)) - |> pog.returning(int_column_decoder()) - |> pog.execute(on: connection) - { - Error(error) -> Error(string.inspect(error)) - Ok(returned) -> - case returned.rows { - [cursor] -> - case cursor < 0 { - True -> Ok(factos.NoPosition) - False -> Ok(factos.SequencePosition(cursor)) - } - [] -> Error("subscription cursor is missing") - [_, _, ..] -> Error("subscription cursor is duplicated") - } - } -} - -fn wait_for_subscription_cursor( - connection: pog.Connection, - name: String, - expected: factos.SequencePosition, - attempts_remaining: Int, -) -> Result(Nil, String) { - let cursor = load_subscription_cursor(connection, name) - case cursor == Ok(expected), attempts_remaining <= 0 { - True, _ -> Ok(Nil) - False, True -> - Error( - "subscription cursor did not advance: expected " - <> string.inspect(expected) - <> ", got " - <> string.inspect(cursor), - ) - False, False -> { - process.sleep(20) - wait_for_subscription_cursor( - connection, - name, - expected, - attempts_remaining - 1, - ) - } - } -} - -fn deliver_managed_subscription_event( - _connection: pog.Connection, - deliveries: process.Subject(ManagedSubscriptionMessage), - event: factos.Recorded(Event), -) -> Result(Nil, String) { - process.send(deliveries, ManagedSubscriptionEvent(event:)) - Ok(Nil) -} - -fn retry_managed_subscription_event_once( - connection: pog.Connection, - deliveries: process.Subject(ManagedSubscriptionMessage), - event: factos.Recorded(Event), -) -> Result(Nil, String) { - use attempts <- result.try(increment_managed_subscription_attempts(connection)) - case attempts { - 1 -> Error("retry managed subscription event once") - _ -> deliver_managed_subscription_event(connection, deliveries, event) - } -} - -fn increment_managed_subscription_attempts( - connection: pog.Connection, -) -> Result(Int, String) { - case - pog.query( - " - update factos_pog_test_subscription - set attempts = attempts + 1 - where singleton = true - returning attempts - ", - ) - |> pog.returning(int_column_decoder()) - |> pog.execute(on: connection) - { - Error(error) -> Error(string.inspect(error)) - Ok(returned) -> - case returned.rows { - [attempts] -> Ok(attempts) - [] -> Error("managed subscription attempt row is missing") - [_, _, ..] -> Error("managed subscription attempt row is duplicated") - } - } -} - fn block_projection( name: String, barriers: process.Subject(ProjectionBarrierMessage), @@ -2626,56 +1485,6 @@ fn block_projection( } } -fn blocking_projection_subscription( - connection: pog.Connection, - config: pog.Config, - name: String, - barriers: process.Subject(ProjectionBarrierMessage), -) -> factos_pog.Subscription(Event, String) { - let assert Ok(subscription) = - factos_pog.new_projection_subscription( - connection:, - config: pog.Config( - ..config, - pool_name: process.new_name(prefix: "blocking-projection-pool"), - ), - name:, - query: factos.AllEvents, - start_from: factos_pog.Current, - codec: codec(), - project: fn(_transaction_connection, recorded) { - block_projection(name, barriers, recorded) - }, - ) - subscription -} - -fn start_dispatch_wait_worker( - connection: pog.Connection, - stream stream_name: String, - username username: String, - subscriptions subscriptions: List(String), - timeout timeout: duration.Duration, - results results: process.Subject(DispatchWaitMessage), -) -> process.Pid { - process.spawn(fn() { - let result = - factos_pog.new_dispatch( - connection:, - stream: stream_name, - decider: decider(), - codec: codec(), - ) - |> factos_pog.dispatch_and_wait( - RegisterUser(username:), - event_id: uuid.v4_string, - subscriptions:, - timeout:, - ) - process.send(results, DispatchWaitCompleted(result:)) - }) -} - fn insert_test_projection( connection: pog.Connection, recorded: factos.Recorded(Event), @@ -2714,21 +1523,6 @@ fn string_pair_decoder() -> decode.Decoder(#(String, String)) { decode.success(#(first, second)) } -fn managed_subscription_attempts(connection: pog.Connection) -> Int { - let assert Ok(returned) = - pog.query( - " - select attempts - from factos_pog_test_subscription - where singleton = true - ", - ) - |> pog.returning(int_column_decoder()) - |> pog.execute(on: connection) - let assert [attempts] = returned.rows - attempts -} - fn stop_test_supervisor(supervisor_pid: process.Pid) -> Nil { process.unlink(supervisor_pid) let monitor = process.monitor(supervisor_pid) @@ -2741,11 +1535,6 @@ fn stop_test_supervisor(supervisor_pid: process.Pid) -> Nil { Nil } -fn int_column_decoder() -> decode.Decoder(Int) { - use value <- decode.field(0, decode.int) - decode.success(value) -} - fn execute_migration_file(connection: pog.Connection) -> Nil { let assert Ok(priv_directory) = application.priv_directory("factos_pog") let assert Ok(sql) = simplifile.read(priv_directory <> "/migrations.sql") @@ -2818,7 +1607,7 @@ fn split_sql_outside( } } -fn assert_event_notification_objects(connection: pog.Connection) -> Nil { +fn assert_event_store_objects(connection: pog.Connection) -> Nil { let assert Ok(returned) = pog.query( " @@ -2836,8 +1625,8 @@ fn assert_event_notification_objects(connection: pog.Connection) -> Nil { 'factos_pog_event_insert_notify' ) union all - select 'factos_subscriptions' - where to_regclass('factos_subscriptions') is not null + select 'factos_subscriptions_absent' + where to_regclass('factos_subscriptions') is null union all select 'factos_outbox' where to_regclass('factos_outbox') is not null @@ -2849,10 +1638,8 @@ fn assert_event_notification_objects(connection: pog.Connection) -> Nil { assert returned.rows == [ "factos_pog_event_append_lock", - "factos_pog_event_insert_notify", "factos_pog_lock_event_append", - "factos_pog_notify_event_appended", - "factos_subscriptions", + "factos_subscriptions_absent", ] } @@ -3013,78 +1800,6 @@ fn username_conformance_query() -> factos.Query { ]) } -fn assert_event_notification_matches( - payload: String, - dispatch: factos_pog.Dispatch(Event), -) -> EventNotificationPayload { - let assert Ok(notification) = - json.parse(payload, using: event_notification_payload_decoder()) - let EventNotificationPayload( - cursor:, - id:, - stream:, - revision:, - type_:, - version:, - tags:, - correlation_id:, - username:, - .., - ) = notification - let assert [ - factos.Recorded( - id: recorded_id, - stream: recorded_stream, - revision: recorded_revision, - position: factos.SequencePosition(position), - event: UserRegistered(username: recorded_username), - descriptor:, - ), - ] = dispatch.events - assert cursor == position - assert id == recorded_id - assert stream == recorded_stream - assert revision == recorded_revision - assert type_ == factos.event_type_name(descriptor.type_) - assert version == descriptor.version - assert tags == list.map(descriptor.tags, factos.tag_value) - let assert Ok(recorded_correlation_id) = - factos.metadata_get(descriptor.metadata, factos.correlation_id) - assert correlation_id == recorded_correlation_id - assert username == recorded_username - notification -} - -fn event_notification_payload_decoder() -> decode.Decoder( - EventNotificationPayload, -) { - use cursor <- decode.field("cursor", decode.int) - use previous_cursor <- decode.field("previous_cursor", decode.int) - use id <- decode.subfield(["event", "id"], decode.string) - use stream <- decode.subfield(["event", "stream"], decode.string) - use revision <- decode.subfield(["event", "revision"], decode.int) - use type_ <- decode.subfield(["event", "type"], decode.string) - use version <- decode.subfield(["event", "version"], decode.int) - use tags <- decode.subfield(["event", "tags"], decode.string |> decode.list) - use correlation_id <- decode.subfield( - ["event", "metadata", factos.correlation_id], - decode.string, - ) - use username <- decode.subfield(["event", "data"], decode.string) - decode.success(EventNotificationPayload( - cursor:, - previous_cursor:, - id:, - stream:, - revision:, - type_:, - version:, - tags:, - correlation_id:, - username:, - )) -} - fn codec() -> factos_pog.EventCodec(Event) { factos_pog.codec(encode:, decode:) } @@ -3118,19 +1833,11 @@ fn decode( } } -fn welcome_reactor() -> factos.Reactor(Event, Effect) { - factos.reactor(react: fn(recorded) { - case recorded.event { - UserRegistered(username) -> [SendWelcome(username)] - } - }) -} - fn dispatch_counter_context_many( connection: pog.Connection, query: factos.Query, remaining: Int, -) -> Result(factos_pog.Dispatch(CounterEvent), factos_pog.Error(Nil)) { +) -> Result(factos_pog.Dispatch(CounterEvent), factos_pog.Error(Nil, Nil)) { case remaining { 0 -> factos_pog.new_dispatch( diff --git a/examples/course_subscriptions/dev/course_subscriptions_dev.gleam b/examples/course_subscriptions/dev/course_subscriptions_dev.gleam index 968a79f..4f3e4a1 100644 --- a/examples/course_subscriptions/dev/course_subscriptions_dev.gleam +++ b/examples/course_subscriptions/dev/course_subscriptions_dev.gleam @@ -33,7 +33,7 @@ type WorkerMessage { worker: String, result: Result( factos_pog.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error), + factos_pog.Error(course_subscription.Error, Nil), ), ) } @@ -240,7 +240,7 @@ fn run_concurrent_final_seat( ) -> List( Result( factos_pog.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error), + factos_pog.Error(course_subscription.Error, Nil), ), ) { let messages = process.new_subject() @@ -292,7 +292,7 @@ fn receive_worker_finished( messages: process.Subject(WorkerMessage), ) -> Result( factos_pog.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error), + factos_pog.Error(course_subscription.Error, Nil), ) { let assert Ok(message) = process.receive(messages, within: 10_000) let assert WorkerFinished(worker: _, result:) = message @@ -302,7 +302,7 @@ fn receive_worker_finished( fn is_accepted( result: Result( factos_pog.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error), + factos_pog.Error(course_subscription.Error, Nil), ), ) -> Bool { case result { @@ -314,7 +314,7 @@ fn is_accepted( fn is_fully_booked( result: Result( factos_pog.Dispatch(course_subscription.Event), - factos_pog.Error(course_subscription.Error), + factos_pog.Error(course_subscription.Error, Nil), ), ) -> Bool { case result { diff --git a/examples/course_subscriptions/src/course_subscription.gleam b/examples/course_subscriptions/src/course_subscription.gleam index d02b3ac..398869c 100644 --- a/examples/course_subscriptions/src/course_subscription.gleam +++ b/examples/course_subscriptions/src/course_subscription.gleam @@ -334,7 +334,7 @@ pub fn dispatch( connection: pog.Connection, command: Command, event_id: fn() -> String, -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error, Nil)) { factos_pog.new_dispatch( connection:, stream: stream(command), diff --git a/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam b/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam index 6d117b4..6f49ef5 100644 --- a/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam +++ b/examples/dynamic_product_price/dev/dynamic_product_price_dev.gleam @@ -32,7 +32,7 @@ type WorkerMessage { worker: String, result: Result( factos_pog.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error), + factos_pog.Error(dynamic_product_price.Error, Nil), ), ) } @@ -256,7 +256,7 @@ fn require_order( fn require_invalid_order( result: Result( factos_pog.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error), + factos_pog.Error(dynamic_product_price.Error, Nil), ), product_id product_id: String, ) -> Nil { @@ -273,7 +273,7 @@ fn run_concurrent_orders( ) -> List( Result( factos_pog.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error), + factos_pog.Error(dynamic_product_price.Error, Nil), ), ) { let messages = process.new_subject() @@ -339,7 +339,7 @@ fn receive_worker_finished( messages: process.Subject(WorkerMessage), ) -> Result( factos_pog.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error), + factos_pog.Error(dynamic_product_price.Error, Nil), ) { let assert Ok(message) = process.receive(messages, within: 10_000) let assert WorkerFinished(worker: _, result:) = message @@ -349,7 +349,7 @@ fn receive_worker_finished( fn is_accepted( result: Result( factos_pog.Dispatch(dynamic_product_price.Event), - factos_pog.Error(dynamic_product_price.Error), + factos_pog.Error(dynamic_product_price.Error, Nil), ), ) -> Bool { case result { diff --git a/examples/dynamic_product_price/src/dynamic_product_price.gleam b/examples/dynamic_product_price/src/dynamic_product_price.gleam index 0cce081..409c720 100644 --- a/examples/dynamic_product_price/src/dynamic_product_price.gleam +++ b/examples/dynamic_product_price/src/dynamic_product_price.gleam @@ -379,7 +379,7 @@ pub fn dispatch( connection: pog.Connection, command: Command, event_id: fn() -> String, -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error, Nil)) { factos_pog.new_dispatch( connection:, stream: stream(command), diff --git a/examples/invoice_number/dev/invoice_number_dev.gleam b/examples/invoice_number/dev/invoice_number_dev.gleam index f9cac3f..3926488 100644 --- a/examples/invoice_number/dev/invoice_number_dev.gleam +++ b/examples/invoice_number/dev/invoice_number_dev.gleam @@ -30,7 +30,7 @@ type WorkerMessage { worker: String, result: Result( factos_pog.Dispatch(invoice_number.Event), - factos_pog.Error(Nil), + factos_pog.Error(Nil, Nil), ), ) } @@ -126,7 +126,7 @@ fn dispatch_invoice_number( fn run_concurrent_invoices( connection: pog.Connection, ) -> List( - Result(factos_pog.Dispatch(invoice_number.Event), factos_pog.Error(Nil)), + Result(factos_pog.Dispatch(invoice_number.Event), factos_pog.Error(Nil, Nil)), ) { let messages = process.new_subject() start_worker( @@ -188,7 +188,7 @@ fn receive_worker_ready( fn receive_worker_finished( messages: process.Subject(WorkerMessage), -) -> Result(factos_pog.Dispatch(invoice_number.Event), factos_pog.Error(Nil)) { +) -> Result(factos_pog.Dispatch(invoice_number.Event), factos_pog.Error(Nil, Nil)) { let assert Ok(message) = process.receive(messages, within: 10_000) let assert WorkerFinished(worker: _, result:) = message result diff --git a/examples/invoice_number/src/invoice_number.gleam b/examples/invoice_number/src/invoice_number.gleam index d94ceae..6b875a6 100644 --- a/examples/invoice_number/src/invoice_number.gleam +++ b/examples/invoice_number/src/invoice_number.gleam @@ -97,7 +97,7 @@ pub fn dispatch( connection: pog.Connection, command: Command, event_id: fn() -> String, -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Nil)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Nil, Nil)) { factos_pog.new_dispatch( connection:, stream: stream(command), diff --git a/examples/opt_in_token/dev/opt_in_token_dev.gleam b/examples/opt_in_token/dev/opt_in_token_dev.gleam index 6b562a2..dac4867 100644 --- a/examples/opt_in_token/dev/opt_in_token_dev.gleam +++ b/examples/opt_in_token/dev/opt_in_token_dev.gleam @@ -32,7 +32,7 @@ type WorkerMessage { worker: String, result: Result( factos_pog.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error), + factos_pog.Error(opt_in_token.Error, Nil), ), ) } @@ -251,7 +251,7 @@ fn require_initiation( fn require_domain_error( result: Result( factos_pog.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error), + factos_pog.Error(opt_in_token.Error, Nil), ), expected expected: opt_in_token.Error, ) -> Nil { @@ -277,7 +277,7 @@ fn run_concurrent_confirmation( ) -> List( Result( factos_pog.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error), + factos_pog.Error(opt_in_token.Error, Nil), ), ) { let messages = process.new_subject() @@ -341,7 +341,7 @@ fn receive_worker_finished( messages: process.Subject(WorkerMessage), ) -> Result( factos_pog.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error), + factos_pog.Error(opt_in_token.Error, Nil), ) { let assert Ok(message) = process.receive(messages, within: 10_000) let assert WorkerFinished(worker: _, result:) = message @@ -351,7 +351,7 @@ fn receive_worker_finished( fn is_accepted( result: Result( factos_pog.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error), + factos_pog.Error(opt_in_token.Error, Nil), ), ) -> Bool { case result { @@ -363,7 +363,7 @@ fn is_accepted( fn is_already_used( result: Result( factos_pog.Dispatch(opt_in_token.Event), - factos_pog.Error(opt_in_token.Error), + factos_pog.Error(opt_in_token.Error, Nil), ), ) -> Bool { case result { diff --git a/examples/opt_in_token/src/opt_in_token.gleam b/examples/opt_in_token/src/opt_in_token.gleam index eafede1..faaea00 100644 --- a/examples/opt_in_token/src/opt_in_token.gleam +++ b/examples/opt_in_token/src/opt_in_token.gleam @@ -276,7 +276,7 @@ pub fn dispatch( connection: pog.Connection, command: Command, event_id: fn() -> String, -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error, Nil)) { factos_pog.new_dispatch( connection:, stream: stream(command), diff --git a/examples/performance/src/factos_pog_performance.gleam b/examples/performance/src/factos_pog_performance.gleam index baa90d5..909b1d2 100644 --- a/examples/performance/src/factos_pog_performance.gleam +++ b/examples/performance/src/factos_pog_performance.gleam @@ -42,7 +42,7 @@ pub type Event { } type WorkerMessage { - WorkerDone(worker: Int, result: Result(Nil, factos_pog.Error(Nil))) + WorkerDone(worker: Int, result: Result(Nil, factos_pog.Error(Nil, Nil))) } type BenchmarkJob { @@ -296,7 +296,7 @@ fn run_worker( benchmark_codec: factos_pog.EventCodec(Event), worker: Int, dispatch_index dispatch_index: Int, -) -> Result(Nil, factos_pog.Error(Nil)) { +) -> Result(Nil, factos_pog.Error(Nil, Nil)) { case dispatch_index >= dispatches_per_worker { True -> Ok(Nil) False -> { @@ -340,7 +340,11 @@ fn wait_for_workers( "performance worker " <> int.to_string(worker) <> " failed: " - <> factos_pog.error_to_string(error, fn(_) { "nil" }) + <> factos_pog.error_to_string( + error, + fn(_) { "nil" }, + fn(_) { "nil" }, + ) } } } @@ -353,7 +357,7 @@ fn dispatch_once( stream_name: String, batch: Int, event_count: Int, -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Nil)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Nil, Nil)) { factos_pog.new_dispatch( connection:, stream: stream_name, diff --git a/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam b/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam index fa9d361..e7388a7 100644 --- a/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam +++ b/examples/prevent_record_duplication/dev/prevent_record_duplication_dev.gleam @@ -31,7 +31,7 @@ type WorkerMessage { worker: String, result: Result( factos_pog.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error), + factos_pog.Error(prevent_record_duplication.Error, Nil), ), ) } @@ -125,7 +125,7 @@ fn run_concurrent_scenario( ) -> List( Result( factos_pog.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error), + factos_pog.Error(prevent_record_duplication.Error, Nil), ), ) { let messages = process.new_subject() @@ -177,7 +177,7 @@ fn receive_worker_finished( messages: process.Subject(WorkerMessage), ) -> Result( factos_pog.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error), + factos_pog.Error(prevent_record_duplication.Error, Nil), ) { let assert Ok(message) = process.receive(messages, within: 10_000) let assert WorkerFinished(worker: _, result:) = message @@ -187,7 +187,7 @@ fn receive_worker_finished( fn is_accepted( result: Result( factos_pog.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error), + factos_pog.Error(prevent_record_duplication.Error, Nil), ), ) -> Bool { case result { @@ -199,7 +199,7 @@ fn is_accepted( fn is_resubmission( result: Result( factos_pog.Dispatch(prevent_record_duplication.Event), - factos_pog.Error(prevent_record_duplication.Error), + factos_pog.Error(prevent_record_duplication.Error, Nil), ), ) -> Bool { case result { diff --git a/examples/prevent_record_duplication/src/prevent_record_duplication.gleam b/examples/prevent_record_duplication/src/prevent_record_duplication.gleam index 943fce9..82983d9 100644 --- a/examples/prevent_record_duplication/src/prevent_record_duplication.gleam +++ b/examples/prevent_record_duplication/src/prevent_record_duplication.gleam @@ -92,7 +92,7 @@ pub fn dispatch( connection: pog.Connection, command: Command, event_id: fn() -> String, -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error, Nil)) { factos_pog.new_dispatch( connection:, stream: stream(command), diff --git a/examples/unique_username/dev/unique_username_dev.gleam b/examples/unique_username/dev/unique_username_dev.gleam index f3239d8..a134432 100644 --- a/examples/unique_username/dev/unique_username_dev.gleam +++ b/examples/unique_username/dev/unique_username_dev.gleam @@ -33,7 +33,7 @@ type WorkerMessage { worker: String, result: Result( factos_pog.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error), + factos_pog.Error(unique_username.Error, Nil), ), ) } @@ -210,7 +210,7 @@ fn require_registration( fn require_claimed( result: Result( factos_pog.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error), + factos_pog.Error(unique_username.Error, Nil), ), username username: String, ) -> Nil { @@ -226,7 +226,7 @@ fn run_concurrent_claim( ) -> List( Result( factos_pog.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error), + factos_pog.Error(unique_username.Error, Nil), ), ) { let messages = process.new_subject() @@ -279,7 +279,7 @@ fn receive_worker_finished( messages: process.Subject(WorkerMessage), ) -> Result( factos_pog.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error), + factos_pog.Error(unique_username.Error, Nil), ) { let assert Ok(message) = process.receive(messages, within: 10_000) let assert WorkerFinished(worker: _, result:) = message @@ -289,7 +289,7 @@ fn receive_worker_finished( fn is_accepted( result: Result( factos_pog.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error), + factos_pog.Error(unique_username.Error, Nil), ), ) -> Bool { case result { @@ -301,7 +301,7 @@ fn is_accepted( fn is_claimed( result: Result( factos_pog.Dispatch(unique_username.Event), - factos_pog.Error(unique_username.Error), + factos_pog.Error(unique_username.Error, Nil), ), ) -> Bool { case result { diff --git a/examples/unique_username/src/unique_username.gleam b/examples/unique_username/src/unique_username.gleam index 970e9dc..e58e582 100644 --- a/examples/unique_username/src/unique_username.gleam +++ b/examples/unique_username/src/unique_username.gleam @@ -242,7 +242,7 @@ pub fn dispatch( connection: pog.Connection, command: Command, event_id: fn() -> String, -) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error)) { +) -> Result(factos_pog.Dispatch(Event), factos_pog.Error(Error, Nil)) { factos_pog.new_dispatch( connection:, stream: stream(command),