Catch up projections
A document export can require every change already saved across the journal, including changes to
other documents. Call CatchUpAsync after the aggregate reply, then query the transactional
projection's read model:
Shared setup
module Program =
open FCQRS.Common
type Document = { Id: string; Title: string; Content: string }
type PublicationResult = Published | Rejected
type PublicationProgress = WaitingForSlug of string | Finished of string * PublicationResult
// An absent publication field in an older snapshot reads as None.
type DocumentState = { Document: Document option; Publication: PublicationProgress option }
type DocumentCommand =
| CreateDocument of Document
| EditDocument of id: string * content: string
| PublishDocument of slug: string
| FinishPublication of slug: string * PublicationResult
type DocumentEvent =
| DocumentCreated of Document
| DocumentEdited of id: string * content: string
| DocumentRejected of reason: string
| PublicationRequested of id: string * slug: string
| PublicationFinished of id: string * slug: string * PublicationResult
let initial = { Document = None; Publication = None }
let decide (command: Command<DocumentCommand>) state =
match command.CommandDetails, state.Document, state.Publication with
// docs:create
| CreateDocument document, None, _ -> DocumentCreated document |> PersistEvent
| CreateDocument _, Some existing, _ -> DocumentCreated existing |> DeferEvent
// docs:end
// docs:edit
| EditDocument(id, content), Some document, None when document.Id = id ->
if System.String.IsNullOrWhiteSpace content then
DocumentRejected "Content must not be blank" |> DeferEvent
elif document.Content = content then
DocumentEdited(id, content) |> DeferEvent
else
DocumentEdited(id, content) |> PersistEvent
| EditDocument _, None, _ -> DocumentRejected "Document does not exist" |> DeferEvent
| EditDocument _, _, _ -> DocumentRejected "Editing closes when publication starts" |> DeferEvent
// docs:end
// docs:publish
| PublishDocument slug, Some document, None ->
if System.String.IsNullOrWhiteSpace slug then
DocumentRejected "Slug must not be blank" |> DeferEvent
else PublicationRequested(document.Id, slug) |> PersistEvent
| PublishDocument slug, Some document, Some(WaitingForSlug current) when slug = current ->
PublicationRequested(document.Id, slug) |> DeferEvent
| PublishDocument slug, Some document, Some(Finished(current, result)) when slug = current ->
PublicationFinished(document.Id, slug, result) |> DeferEvent
| FinishPublication(slug, result), Some document, Some(WaitingForSlug current) when slug = current ->
PublicationFinished(document.Id, slug, result) |> PersistEvent
| FinishPublication(slug, result), Some document, Some(Finished(current, previous))
when slug = current && result = previous ->
PublicationFinished(document.Id, slug, result) |> DeferEvent
| _ -> DocumentRejected "Publication does not match the document's state" |> DeferEvent
// docs:end
let fold (event: Event<DocumentEvent>) state =
match event.EventDetails with
| DocumentCreated document -> { state with Document = Some document }
| DocumentEdited(id, content) ->
match state.Document with
| Some document when document.Id = id ->
{ state with Document = Some { document with Content = content } }
| _ -> failwith "DocumentEdited requires an earlier DocumentCreated"
| DocumentRejected _ -> state
| PublicationRequested(_, slug) -> { state with Publication = Some(WaitingForSlug slug) }
| PublicationFinished(_, slug, result) -> { state with Publication = Some(Finished(slug, result)) }
open System
open FCQRS.Model.Data
open FCQRS.Common
open FCQRS.FSharp
open Program
open System.Threading
open FCQRS.Projections
let saveAndCatchUp (documents: AggregateHandle<DocumentCommand, DocumentEvent>)
(projection: IProjection) cid documentId doc (cancellationToken: CancellationToken) = async {
Fcqrs_450-how-to_006-catch-up-projections.md_page.ProgramFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Fcqrs_450-how-to_006-catch-up-projections.md_page.Program.DocumentId: stringstringAn abbreviation for the CLI type . Basic Types
Title: stringContent: stringFcqrs_450-how-to_006-catch-up-projections.md_page.Program.PublicationResultPublishedRejectedFcqrs_450-how-to_006-catch-up-projections.md_page.Program.PublicationProgressWaitingForSlugFinishedFcqrs_450-how-to_006-catch-up-projections.md_page.Program.DocumentStateDocument: Document optionoptionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
Publication: PublicationProgress optionFcqrs_450-how-to_006-catch-up-projections.md_page.Program.DocumentCommandCreateDocumentEditDocumentid: stringcontent: stringPublishDocumentslug: stringFinishPublicationFcqrs_450-how-to_006-catch-up-projections.md_page.Program.DocumentEventDocumentCreatedDocumentEditedDocumentRejectedreason: stringPublicationRequestedPublicationFinishedinitial: DocumentStateNoneThe representation of "No value"
decide: Command<DocumentCommand> -> DocumentState -> EventAction<DocumentEvent>command: Command<DocumentCommand>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
state: DocumentStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
document: Document(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
existing: DocumentDeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version.
(=): 'T -> 'T -> boolStructural equality The first parameter. The second parameter. The result of the comparison. 5 = 5 // Evaluates to true 5 = 6 // Evaluates to false [1; 2] = [1; 2] // Evaluates to true (1, 5) = (1, 6) // Evaluates to false
SystemIsNullOrWhiteSpace: string -> boolIndicates whether a specified string is , empty, or consists only of white-space characters. The string to test. if the parameter is or , or if consists exclusively of white-space characters.
System.StringRepresents text as a sequence of UTF-16 code units.
current: stringresult: PublicationResultprevious: PublicationResult(&&): bool -> bool -> boolBinary 'and'. When used as a binary operator the right hand value is evaluated only on demand The first value. The second value. The result of the operation.
fold: Event<DocumentEvent> -> DocumentState -> DocumentStateevent: Event<DocumentEvent>FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
EventDetails: 'EventDetailsThe specific details or payload of the event.
failwith: string -> 'TThrow a exception. The exception message. Never returns. let failingFunction() = failwith "Oh no" // Throws an exception true // Never reaches this failingFunction() // Throws a System.Exception
ModelFCQRS.Model.DataFCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
ThreadingFCQRS.ProjectionsTransactional projections with a journal-wide, durable catch-up boundary.
saveAndCatchUp: AggregateHandle<DocumentCommand,DocumentEvent> -> IProjection -> CID -> AggregateId -> Document -> CancellationToken -> Async<unit>documents: AggregateHandle<DocumentCommand,DocumentEvent>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
projection: IProjectionFCQRS.Projections.IProjectionA transactional projection and its request-scoped notification subscriptions. Catch-up covers every persistence ID in one committed journal snapshot. It does not wait for other projections, later writes, or external effects.
cid: CIDdocumentId: AggregateIddoc: DocumentcancellationToken: CancellationTokenSystem.Threading.CancellationTokenPropagates notification that operations should be canceled.
async: AsyncBuilderBuilds an asynchronous workflow using computation expression syntax. let sleepExample() = async { printfn "sleeping" do! Async.Sleep 10 printfn "waking up" return 6 } sleepExample() |> Async.RunSynchronously
let! reply = documents.Send cid documentId (CreateDocument doc) (fun _ -> true)
do! projection.CatchUpAsync(cancellationToken) |> Async.AwaitTask
// Inspect the command outcome, then query the projected documents.
reply: Event<DocumentEvent>documents: AggregateHandle<DocumentCommand,DocumentEvent>Send: CID -> AggregateId -> 'Command -> ('Event -> bool) -> Async<Event<'Event>>Send a command and await the first matching aggregate event.
cid: CIDdocumentId: AggregateIdCreateDocumentdoc: Documentprojection: IProjectionCatchUpAsync: CancellationToken -> Tasks.TaskCaptures a fixed journal snapshot and waits for this projection to commit every event through it. Cancellation/timeout stops the caller's wait, not a transaction already being processed. Call after the aggregate persistence acknowledgement. The caller's ambient TransactionScope is suppressed; projection commits are independent.
cancellationToken: CancellationToken(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Control.FSharpAsyncHolds static members for creating and manipulating asynchronous computations. See also F# Language Guide - Async Workflows. Async Programming
AwaitTask: Tasks.Task -> Async<unit>Return an asynchronous computation that will wait for the given task to complete and return its result. The task to await. If an exception occurs in the asynchronous computation then an exception is re-raised by this function. If the task is cancelled then is raised. Note that the task may be governed by a different cancellation token to the overall async computation where the AwaitTask occurs. In practice you should normally start the task with the cancellation token returned by let! ct = Async.CancellationToken, and catch any at the point where the overall async is started. Awaiting Results
var reply = await documents(
_ => true, cid, documentId, new CreateDocument(document));
await projection.CatchUpAsync(cancellationToken);
// Inspect the command outcome, then query the projected documents.
These transactional projection APIs are available in FCQRS 6.4.0 and later.
CatchUpAsync captures one journal snapshot: the highest committed sequence number for each
persistence identity visible in one database read. A persistence identity identifies one actor's
journal history. The call succeeds after this projection has committed every event through those
captured sequence numbers. The snapshot includes aggregate events and saga journal records.
The order matters. Await the aggregate reply before calling CatchUpAsync so the snapshot includes
that event when the reply was journaled. It also includes every other event committed before the
snapshot, across all persistence identities in this journal. Writes arriving after the snapshot do
not extend this call's target.
The checkpoint is durable, so this wait does not require a correlation subscription before sending. It also works after a deferred reply, although a deferred reply itself adds no journal event. Check the command outcome separately: projection completion does not turn a rejected command into a successful one.
The examples use the document types from samples/getting-started-fsharp/Document.fs and
samples/getting-started-csharp/Document.cs.
Register a transactional projection
Use Fcqrs.transactionalProjection in F# or AddTransactionalProjection in C#. These are separate
from the offset-based registration in Add a projection. The transactional
runner stores one checkpoint per persistence identity under a stable projection name.
The application supplies a SQL connection factory and a handler. FCQRS opens a transaction, passes its connection and transaction to the handler, records progress, and commits them together. Return from the handler only when its writes have completed. Use the supplied transaction for every read-model change covered by this projection.
Use the Documents table from Add a projection. The following SQLite
registration uses one database for the journal, read model, and FCQRS-owned progress tables:
Shared setup
}
open System.Data.Common
open System.Threading.Tasks
open Akka.Persistence.Query
open Dapper
open Microsoft.Data.Sqlite
open FCQRS.ProjectionStorage
let connectionString = "Data Source=app.db;"
let configuration = Microsoft.Extensions.Configuration.ConfigurationBuilder().Build()
let loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(fun _ -> ())
module Sqlite =
let api = Fcqrs.actor configuration loggerFactory
(Some(Fcqrs.connect FCQRS.Actor.DBType.Sqlite connectionString)) "documents"
SystemDataCommonThreadingTasksAkkaPersistenceQueryDapperMicrosoftSqliteFCQRSFCQRS.ProjectionStorageSQL storage for journal-wide, transactional projection catch-up.
connectionString: stringconfiguration: Extensions.Configuration.IConfigurationRoot``.ctor``: unit -> unitExtensionsConfigurationBuild: unit -> Extensions.Configuration.IConfigurationRootBuilds an with keys and values from the set of providers registered in . An with keys and values from the registered providers.
loggerFactory: Extensions.Logging.ILoggerFactoryCreate: Action<Extensions.Logging.ILoggingBuilder> -> Extensions.Logging.ILoggerFactoryCreates new instance of configured using provided delegate. A delegate to configure the . The that was created.
LoggingMicrosoft.Extensions.Logging.LoggerFactoryProduces instances of classes based on the given providers.
Fcqrs_450-how-to_006-catch-up-projections.md_page.Sqliteapi: IActorFCQRS.FSharp.Fcqrsactor: Extensions.Configuration.IConfiguration -> Extensions.Logging.ILoggerFactory -> FCQRS.Actor.Connection option -> string -> IActorCreate the actor system from plain values (cluster name as a string).
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
connect: FCQRS.Actor.DBType -> string -> FCQRS.Actor.ConnectionBuild a SQLite/etc. Connection from a raw connection string (ShortString hidden).
SqliteSQLite using Microsoft.Data.Sqlite provider
FCQRS.ActorFCQRS.Actor.DBTypeRepresents the type of database connection
// NuGet: Dapper, Microsoft.Data.Sqlite
open System
open System.Data.Common
open System.Threading.Tasks
open Akka.Persistence.Query
open Dapper
open Microsoft.Data.Sqlite
open FCQRS.Common
open FCQRS.FSharp
open FCQRS.ProjectionStorage
open FCQRS.Projections
open Program
let handle (connection: DbConnection) (transaction: DbTransaction)
(envelope: EventEnvelope) : Task =
task {
match envelope.Event with
| :? Event<DocumentEvent> as stored ->
match stored.EventDetails with
| DocumentCreated doc ->
let! _ = connection.ExecuteAsync(
"insert into Documents (Id, Title, Body) values (@Id, @Title, @Body) " +
"on conflict (Id) do update set Title = excluded.Title, Body = excluded.Body",
{| Id = doc.Id; Title = doc.Title; Body = doc.Content |},
transaction)
()
| DocumentEdited(id, content) ->
let! rows = connection.ExecuteAsync(
"update Documents set Body = @Body where Id = @Id",
{| Id = id; Body = content |}, transaction)
if rows <> 1 then failwith "DocumentEdited requires an earlier DocumentCreated"
| _ -> ()
| _ -> ()
} :> Task
let store =
SqlProjectionStore(
ProjectionSqlDialect.Sqlite,
Func<DbConnection>(fun () -> new SqliteConnection(connectionString) :> DbConnection))
let options = TransactionalProjectionOptions("DocumentProjection", store)
let projection = Fcqrs.transactionalProjection api options handle
SystemDataCommonThreadingTasksAkkaPersistenceQueryDapperMicrosoftSqliteFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
FCQRS.ProjectionStorageSQL storage for journal-wide, transactional projection catch-up.
FCQRS.ProjectionsTransactional projections with a journal-wide, durable catch-up boundary.
Fcqrs_450-how-to_006-catch-up-projections.md_page.Programhandle: DbConnection -> DbTransaction -> EventEnvelope -> Taskconnection: DbConnectionSystem.Data.Common.DbConnectionDefines the core behavior of database connections and provides a base class for database-specific connections.
transaction: DbTransactionSystem.Data.Common.DbTransactionDefines the core behavior of database transactions and provides a base class for database-specific transactions.
envelope: EventEnvelopeAkka.Persistence.Query.EventEnvelopeEvent wrapper adding meta data for the events in the result stream of query, or similar queries. The is the time the event was stored, in ticks. The value of this property represents the number of 100-nanosecond intervals that have elapsed since 12:00:00 midnight, January 1, 0001 in the Gregorian calendar (same as `DateTime.Now.Ticks`).
System.Threading.Tasks.TaskRepresents an asynchronous operation.
task: TaskBuilderBuilds a task using computation expression syntax.
Event: objFCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
Fcqrs_450-how-to_006-catch-up-projections.md_page.Program.DocumentEventstored: Event<DocumentEvent>EventDetails: 'EventDetailsThe specific details or payload of the event.
DocumentCreateddoc: DocumentExecuteAsync: string * obj * Data.IDbTransaction * Nullable<int> * Nullable<Data.CommandType> -> Task<int>Execute a command asynchronously using Task. The connection to query on. The SQL to execute for this query. The parameters to use for this query. The transaction to use for this query. Number of seconds before command execution timeout. Is it a stored proc or a batch? The number of rows affected.
(+): ^T1 -> ^T2 -> ^T3Overloaded addition operator The first parameter. The second parameter. The result of the operation. 2 + 2 // Evaluates to 4 "Hello " + "World" // Evaluates to "Hello World"
Id: stringTitle: stringBody: stringContent: stringDocumentEditedid: stringcontent: stringrows: int(<>): 'T -> 'T -> boolStructural inequality The first parameter. The second parameter. The result of the comparison. 5 <> 5 // Evaluates to false 5 <> 6 // Evaluates to true [1; 2] <> [1; 2] // Evaluates to false
failwith: string -> 'TThrow a exception. The exception message. Never returns. let failingFunction() = failwith "Oh no" // Throws an exception true // Never reaches this failingFunction() // Throws a System.Exception
store: SqlProjectionStore``.ctor``: ProjectionSqlDialect * Func<DbConnection> -> SqlProjectionStoreUses one database for both the journal and the transactional read model.
FCQRS.ProjectionStorage.ProjectionSqlDialectDatabase SQL syntax supported by the transactional projection store.
Sqlite: ProjectionSqlDialectSQLite with a provider such as Microsoft.Data.Sqlite.
System.Func`1Encapsulates a method that has no parameters and returns a value of the type specified by the parameter. The type of the return value of the method that this delegate encapsulates. The return value of the method that this delegate encapsulates.
Microsoft.Data.Sqlite.SqliteConnectionRepresents a connection to a SQLite database. Connection Strings Async Limitations
connectionString: stringoptions: TransactionalProjectionOptions``.ctor``: string * SqlProjectionStore -> TransactionalProjectionOptionsprojection: IProjectionFCQRS.FSharp.FcqrstransactionalProjection: IActor -> TransactionalProjectionOptions -> (DbConnection -> DbTransaction -> EventEnvelope -> Task) -> IProjectionStarts a transactional projection with journal-wide CatchUpAsync support. Write the read model through the supplied connection and transaction; FCQRS commits those updates and contiguous per-persistence-ID progress together. Retain unprocessed journal history. Events have per-persistence-ID ordering, with no ordering between different persistence IDs. See TransactionalProjectionOptions.
api: IActor// NuGet: Dapper, Microsoft.Data.Sqlite
using System.Data.Common;
using Akka.Persistence.Query;
using Dapper;
using Microsoft.Data.Sqlite;
using FCQRS;
using static FCQRS.Common;
using static FCQRS.ProjectionStorage;
using static FCQRS.Projections;
static async Task Handle(
DbConnection connection, DbTransaction transaction, EventEnvelope envelope)
{
if (envelope.Event is not Event<DocumentEvent> stored) return;
switch (stored.EventDetails)
{
case DocumentCreated created:
await connection.ExecuteAsync(
"insert into Documents (Id, Title, Body) values (@Id, @Title, @Body) " +
"on conflict (Id) do update set Title = excluded.Title, Body = excluded.Body",
new {
Id = created.Document.Id,
Title = created.Document.Title,
Body = created.Document.Content
}, transaction);
break;
case DocumentEdited edited:
var rows = await connection.ExecuteAsync(
"update Documents set Body = @Body where Id = @Id",
new { Id = edited.Id, Body = edited.Content }, transaction);
if (rows != 1)
throw new InvalidOperationException("DocumentEdited requires an earlier DocumentCreated");
break;
}
}
var store = new SqlProjectionStore(
ProjectionSqlDialect.Sqlite, () => new SqliteConnection(connectionString));
var options = new TransactionalProjectionOptions("DocumentProjection", store);
builder.Services.AddFcqrs(connectionString, "document-system")
.AddAggregate<DocumentAggregate>()
.AddTransactionalProjection(options, Handle);
The C# registration adds IProjection to dependency injection. Inject it into the caller that waits
for catch-up. The F# facade returns the same interface. It also implements ISubscribe, so existing
correlation subscriptions remain available, with aggregate notifications published after commit.
Subscriptions belong to the local worker. If several workers share the same projection name and
store, a worker can observe progress committed by another worker without publishing that other
worker's notifications. Use CatchUpAsync for the durable completion boundary across those workers.
The factory must return a new, unopened connection. For separate journal and read-model databases,
use the constructor taking journalConnectionFactory and projectionConnectionFactory. Both
databases must use the selected dialect. The journal factory must read the authoritative database;
the projection factory must open the store updated by the handler. Journal schema, table, and
persistence-ID and sequence-number column overrides must match the Akka journal configuration.
The runner validates the effective HOCON settings for both SQL journal readers and writers.
DataOptionsSetup and MultiDataOptionsSetup overrides are unsupported because they can replace
those settings. If akka.persistence.query.journal.sql.write-plugin is set, it must identify the
active write journal. Configured Akka event adapters are also unsupported by this reader.
For PostgreSQL, add the Npgsql package to the application, choose ProjectionSqlDialect.PostgreSql,
and configure the Akka journal for the same database. The handler above uses SQL accepted by both
SQLite and PostgreSQL:
Shared setup
module PostgreSql =
let handle = Sqlite.handle
Fcqrs_450-how-to_006-catch-up-projections.md_page.PostgreSqlhandle: DbConnection -> DbTransaction -> EventEnvelope -> TaskFcqrs_450-how-to_006-catch-up-projections.md_page.Sqlite open Npgsql
let store =
SqlProjectionStore(
ProjectionSqlDialect.PostgreSql,
Func<DbConnection>(fun () -> new NpgsqlConnection(connectionString) :> DbConnection))
let options = TransactionalProjectionOptions("DocumentProjection", store)
let api =
Fcqrs.actor configuration loggerFactory
(Some(Fcqrs.connect FCQRS.Actor.DBType.PostgreSQL15 connectionString)) "document-system"
let projection = Fcqrs.transactionalProjection api options handle
Npgsqlstore: SqlProjectionStore``.ctor``: ProjectionSqlDialect * Func<DbConnection> -> SqlProjectionStoreUses one database for both the journal and the transactional read model.
FCQRS.ProjectionStorage.ProjectionSqlDialectDatabase SQL syntax supported by the transactional projection store.
PostgreSql: ProjectionSqlDialectPostgreSQL with a provider such as Npgsql.
System.Func`1Encapsulates a method that has no parameters and returns a value of the type specified by the parameter. The type of the return value of the method that this delegate encapsulates. The return value of the method that this delegate encapsulates.
System.Data.Common.DbConnectionDefines the core behavior of database connections and provides a base class for database-specific connections.
Npgsql.NpgsqlConnectionThis class represents a connection to a PostgreSQL server.
connectionString: stringoptions: TransactionalProjectionOptions``.ctor``: string * SqlProjectionStore -> TransactionalProjectionOptionsapi: IActorFCQRS.FSharp.Fcqrsactor: Extensions.Configuration.IConfiguration -> Extensions.Logging.ILoggerFactory -> FCQRS.Actor.Connection option -> string -> IActorCreate the actor system from plain values (cluster name as a string).
configuration: Extensions.Configuration.IConfigurationRootloggerFactory: Extensions.Logging.ILoggerFactorySomeThe representation of "Value of type 'T" The input value. An option representing the value.
connect: FCQRS.Actor.DBType -> string -> FCQRS.Actor.ConnectionBuild a SQLite/etc. Connection from a raw connection string (ShortString hidden).
FCQRSPostgreSQL15PostgreSQL 15+
FCQRS.ActorFCQRS.Actor.DBTypeRepresents the type of database connection
projection: IProjectiontransactionalProjection: IActor -> TransactionalProjectionOptions -> (DbConnection -> DbTransaction -> EventEnvelope -> Task) -> IProjectionStarts a transactional projection with journal-wide CatchUpAsync support. Write the read model through the supplied connection and transaction; FCQRS commits those updates and contiguous per-persistence-ID progress together. Retain unprocessed journal history. Events have per-persistence-ID ordering, with no ordering between different persistence IDs. See TransactionalProjectionOptions.
handle: DbConnection -> DbTransaction -> EventEnvelope -> Taskvar store = new SqlProjectionStore(
ProjectionSqlDialect.PostgreSql, () => new Npgsql.NpgsqlConnection(connectionString));
var options = new TransactionalProjectionOptions("DocumentProjection", store);
builder.Services.AddFcqrs(
connectionString, "document-system", FCQRS.Actor.DBType.PostgreSQL15)
.AddAggregate<DocumentAggregate>()
.AddTransactionalProjection(options, Handle);
The snapshot and completion contract uses per-identity checkpoints on both databases.
FCQRS owns the transaction and progress tables. Keep the handler's connection and transaction inside the handler, and let FCQRS commit or roll back. Dispose the F# projection handle when its runtime scope ends; the C# host manages the registered projection's lifetime.
Understand completion and ordering
Transactional projections handle events in sequence within each persistence identity. They do not promise a global processing order across identities. A handler combining facts from different aggregates must tolerate their arrival order or explicitly coordinate those dependencies.
The runner requires a complete history through each captured target. It does not treat the largest observed sequence number as proof that missing earlier events were handled. Preserve journal events needed by this projection, and begin with a new read model when starting a new checkpoint history. An existing global offset cannot establish these per-identity checkpoints.
The completion boundary covers one projection and the database transaction used by its handler. It does not wait for another projection, an external HTTP call, or work started without awaiting it. A query can observe later committed changes too; completion does not freeze the read model at the captured snapshot.
Catch-up suppresses an ambient TransactionScope so the snapshot sees freshly committed journal
data. Projection commits belong to FCQRS transactions independently of the caller's transaction;
rolling back the caller's scope does not roll back projection work.
For a user-disable workflow, this wait can establish that the selected projection processed every event committed before the disable event and the subsequent snapshot. Rejecting later user commands remains an aggregate or application rule. The wait does not drain commands that were sent earlier but have not yet been persisted, and it does not stop future writes.
Configure discovery and waiting
TransactionalProjectionOptions configures the runner:
| Property | Default | Meaning |
|---|---|---|
PollInterval |
1 second | Background delay before discovering new journal heads after the previous batch finishes |
BatchSize |
500 | Maximum events fetched for one persistence identity per query |
CatchUpTimeout |
30 seconds | Time allowed for the entire catch-up call, including snapshot capture |
Background discovery follows new events as the application runs. Each CatchUpAsync call captures
its own fixed target once; it does not repeatedly replace the target with a newer journal tail.
Discovery queries the journal with GROUP BY persistence_id to find each history's head. This query
can be costly for a large journal. Increase PollInterval to reduce background query frequency when
the application's latency requirements allow it. Each explicit catch-up call also captures these
heads once.
The catch-up timeout belongs to TransactionalProjectionOptions, separately from
akka.fcqrs.command-timeout used by Read your writes.
Recover without losing progress
Read-model updates and their checkpoint share one transaction. If the handler fails before commit, neither becomes durable. After restart, use the same projection name and store to resume from the committed checkpoints. The handler can run again for an event whose transaction did not commit; external side effects therefore need their own retry and idempotency policy.
An error while processing journal events stops the runner and faults IProjection.Completion.
Catch-up calls then report the error. Observe this task in the application's worker or health
monitoring, correct the cause, and restart the projection. Missing sequence numbers stop the runner
instead of silently advancing its checkpoint.
Cancellation or TimeoutException ends the caller's wait. It does not undo the aggregate command or read-model
transactions that have already committed. Treat the result as an incomplete confirmation, then
retry catch-up or report that the read model has not yet been confirmed current.
The repository's facade tests cover catch-up and recovery:
dotnet run --project test/Facade.Tests/Facade.Tests.fsproj
Set FCQRS_TEST_POSTGRES to a PostgreSQL test-server connection string to include the PostgreSQL
integration cases. The test account needs permission to create and drop the tests' isolated
databases. CI runs these cases against its PostgreSQL service. Test your domain
covers tests for the application's own decisions and replay rules.
For rebuilding query data, continue with Rebuild a read model. For a request that needs one matching notification, use Read your writes.