ProjectionEngine
ProjectionEngine is the runtime of the projection module. A Projection[A] only describes a read model: which sources it reads, how events are routed, and what each event does to a row. The engine is what runs those descriptions. It reads events from EventStores, runs the matching handler for each event, applies the resulting action to a ProjectionStore, and answers queries from the stores. Its fundamental operations are ProjectionEngine#start and ProjectionEngine#query.
ProjectionEngine:
- Resumes where it stopped. After every event it applies, the engine saves the event's sequence number as the projection's watermark, the position of the last event applied. After a restart it continues with the events after the watermark.
- Catches up before it returns.
startfirst applies the stored events after the watermark, a step called catch-up, and returns only when every source has finished it. Each source then gets a live fiber, a background ZIO fiber that applies the new events as they are appended. - Keeps each source in order. The events of one source are processed one at a time, in the order they were appended. Separate sources and separate projections run on separate fibers, with no order between them.
- Is eventually consistent. Events appended after
startare applied in the background, so a query right after an append may not see the change yet. - Runs inside a
Scope. A ZIOScopeowns resources and background fibers, and releases them when it closes. The fibers of the engine live until theScopegiven tostartcloses. There is no stop method.
This is the API that applications run. Tests usually use TestEngine instead, which wires an engine on in-memory stores. Add the module and the sqlite-jdbc driver as described under Installation. The module is built for the JVM only, and for Scala 3 only (3.3.7 and 3.9.0).
The class has a private constructor, so engines are created with the factories on its companion object. Its public members are:
final class ProjectionEngine private[projection] (
// the projections, their stores, the event stores and the cache are private
val config: ProjectionEngineConfig // the configuration the engine was created with
// ...
) {
def transactorCache: TransactorCache // the cache the engine opens SQLite files with
def registerMigration[Old, New](spec: Projection[New], migration: Migration[Old, New]): Task[Unit]
def start: ZIO[Scope, Nothing, Unit]
def query[A](spec: Projection[A], entityId: String): Task[Option[A]]
def queryByName[A](specName: String, entityId: String): Task[Option[A]]
def getLastProcessedSeq[A](spec: Projection[A]): Task[Long]
def storesMap: Map[String, ProjectionStore[_]] // the primary store of each projection, by name; no shards
def eventStoresMap: Map[String, EventStore[_]] // the event stores, by source name
}
Migration is zio.blocks.schema.migration.Migration. The class has no type parameter: each method that needs a row type takes it as its own type parameter.
Usage
The program below defines an event, a read model and a projection. It opens an event store and a projection store on SQLite files, creates the engine with ProjectionEngine.makeWithStores, and appends two events before it starts the engine. Because start returns only after catch-up, the queries right after it already see both rows, and the watermark is the sequence number of the second event:
/*
* Example 13 of 13: catch-up in `start`.
*
* Read Example1QuickStart first.
*
* The program of the Usage section of the `ProjectionEngine` page in the
* documentation.
*
* `ProjectionEngine#start` first applies the events that are already stored, a
* step called catch-up, and returns only when it is done. Only then does the
* engine follow new events, on a background fiber. So the events appended here
* before `start` can be read right after it, without polling. After each event
* it applies, the engine saves the event's sequence number in the projection's
* store. That number, the watermark, is where it resumes on the next start.
*
* It shows:
* - opening an event store and a projection store, and giving them to
* `ProjectionEngine.makeWithStores`
* - events appended before `start`, applied by catch-up and read right after
* it
* - the watermark, read with `getLastProcessedSeq`
*
* This example runs on real SQLite files: the events are kept in
* `.projection-examples/example13/events/users.db`, and the projection in
* `.projection-examples/example13/users/user_profiles.db`.
*
* Run it from the repository root with:
* sbt "projection-examples/runMain projection.examples.Example13EngineCatchUp"
*/
package projection.examples
import zio.*
import zio.blocks.projection.*
import zio.blocks.schema.{Modifier, Schema}
object Example13EngineCatchUp extends ZIOAppDefault {
// The event. The event store holds this one type.
case class UserCreated(name: String, email: String)
object UserCreated {
implicit val schema: Schema[UserCreated] = Schema.derived[UserCreated]
}
// The read model, one row per user. `@Modifier.id` marks the ID field, and `EntityPath.derived` finds it.
case class UserProfile(@Modifier.id userId: String, name: String, email: String)
object UserProfile {
implicit val schema: Schema[UserProfile] = Schema.derived[UserProfile]
implicit val entityPath: EntityPath[UserProfile] = EntityPath.derived[UserProfile]
}
// The projection: each `UserCreated` inserts the row of the user it was recorded under
val projection: Projection[UserProfile] = Projection[UserProfile]("user_profiles")
.from("users")
.routeToSelf
.on[UserCreated]
.insert((e, ctx) => UserProfile(ctx.entityId, e.name, e.email))
private def show(row: Option[UserProfile]): String =
row.map(u => s"${u.name} <${u.email}>").getOrElse("not found")
def demo: ZIO[Any, Throwable, Unit] =
Console.printLine("\n--- Example 13: Catch-up in start ---").orDie *>
ZIO.scoped {
for {
_ <- Sqlite.folders(Sqlite.path("example13/events"), Sqlite.path("example13/users"))
cache <- TransactorCache.make()
tx <- cache.get(Sqlite.path("example13/events/users.db"))
users <- SQLiteEventStore.make[UserCreated](tx)
profiles <- SQLiteProjectionStore.make[UserProfile](Sqlite.path("example13/users/user_profiles.db"), cache)
engine <- ProjectionEngine.makeWithStores(
specs = List(projection),
stores = Map("user_profiles" -> profiles), // projection name -> its primary store
eventStores = Map("users" -> users), // source name given to `from` -> its event store
cache = cache
)
// Appended before `start`, so catch-up applies them
_ <- users.append("user-1", UserCreated("Alice", "alice@example.com"))
_ <- users.append("user-2", UserCreated("Bob", "bob@example.com"))
// `start` returns only after catch-up, so both rows are there right away, with no polling
_ <- engine.start
alice <- engine.query(projection, "user-1")
bob <- engine.query(projection, "user-2")
seq <- engine.getLastProcessedSeq(projection)
_ <- Console.printLine(s" user-1: ${show(alice)}").orDie
_ <- Console.printLine(s" user-2: ${show(bob)}").orDie
_ <- Console.printLine(s" Watermark: $seq").orDie
} yield ()
}
def run: ZIO[Any, Throwable, Unit] = demo
}
It prints user-1: Alice <alice@example.com>, user-2: Bob <bob@example.com> and Watermark: 2. When ZIO.scoped ends, its Scope closes, so the engine's live fiber stops and the cache closes. The files stay on disk, so an engine started on them again would continue after event 2.
Creating an Engine
The companion object has four factories. They differ in what they create for you:
ProjectionEngine.makeWithStorestakes everything: the primary store of each projection, the event stores, and aTransactorCache. It needs noScope, and an invalid projection name or folder is a defect: the effect dies, instead of failing with an error thatcatchAllcan handle.ProjectionEngine.makeWithStoresVarargstakes the event stores and aTransactorCache, and creates a SQLite primary store for each projection. It needs noScope, and an invalid name or folder is a typed failure: the task fails with anIllegalArgumentException, whichcatchAllcan handle.ProjectionEngine.makeandProjectionEngine.makeWithConfigtake only the projections. They create the primary stores and aTransactorCachethat is closed with the enclosingScope, so they need one. An invalid name or folder is a typed failure. They give the engine no event stores, so it has nothing to read.
The primary store of a projection is the store the engine reads and writes for it, and where it keeps its watermark. None of the factories opens a file: a store opens its file the first time it is used, so a missing driver or folder shows up then.
From Your Own Stores — makeWithStores
ProjectionEngine.makeWithStores builds an engine from stores you created yourself. It is the factory the Usage example uses, and the one TestEngine uses for its in-memory stores:
object ProjectionEngine {
def makeWithStores(
specs: List[Projection[_]],
stores: Map[String, ProjectionStore[_]],
eventStores: Map[String, EventStore[_]],
cache: TransactorCache,
config: ProjectionEngineConfig = ProjectionEngineConfig.default
): UIO[ProjectionEngine]
}
storesis keyed by projection name, the name given toProjection[A](name).eventStoresis keyed by source name, the name given tofrom. How a source finds its store is described understart.storesmust have an entry for every projection. This is not checked when the engine is created: a missing entry makesstartorquerydie with aNoSuchElementException.- The names and folders of the projections are checked with
validateNameandvalidateBasePath. An invalid one is a defect, which is why the result is aUIO. cachealso opens the SQLite files of the shards of cross-entity projections. A shard is the store of one routing key, and a routing key is the value thatroutedByreads from an event to choose the shard; see CrossEntity. The engine never closes the cache: close it yourself, or create it withTransactorCache.makeinside aScope.
Creating the Primary Stores — makeWithStoresVarargs
ProjectionEngine.makeWithStoresVarargs takes the event stores, like makeWithStores, but creates a SQLiteProjectionStore for each projection itself. The projections go in a second parameter list:
object ProjectionEngine {
def makeWithStoresVarargs(
cache: TransactorCache,
eventStores: Map[String, EventStore[_]],
config: ProjectionEngineConfig = ProjectionEngineConfig.default
)(specs: Projection[_]*): Task[ProjectionEngine]
}
Each primary store is the file <basePath>/<name>.db, where basePath comes from the projection's EntityPath, or global/<name>.db for a global projection. The paths are relative to the working directory, and SQLite does not create folders, so the folders must exist before the stores are used.
The snippet below runs the projection from Usage and a second projection of the same row type, each reading its own event store. Both primary stores land in users/, the folder derived from UserProfile, so the program creates events/ and users/ first:
import java.nio.file.{Files, Paths}
val admins: Projection[UserProfile] = Projection[UserProfile]("admin_profiles")
.from("admins")
.routeToSelf
.on[UserCreated]
.insert((e, ctx) => UserProfile(ctx.entityId, e.name, e.email))
val twoSources: ZIO[Scope, Throwable, ProjectionEngine] = for {
_ <- ZIO.attemptBlocking(List("events", "users").foreach(d => Files.createDirectories(Paths.get(d))))
cache <- TransactorCache.make()
users <- cache.get("events/users.db").flatMap(tx => SQLiteEventStore.make[UserCreated](tx))
admin <- cache.get("events/admins.db").flatMap(tx => SQLiteEventStore.make[UserCreated](tx))
engine <- ProjectionEngine.makeWithStoresVarargs(
cache,
Map[String, EventStore[?]]("users" -> users, "admins" -> admin)
)(projection, admins)
_ <- engine.start
} yield engine
The engine reads users/user_profiles.db and users/admin_profiles.db. Like makeWithStores, the factory registers no finalizer, so the cache here is closed by the Scope that TransactorCache.make runs in.
Without Event Stores — make and makeWithConfig
ProjectionEngine.make and ProjectionEngine.makeWithConfig take only the projections. make uses ProjectionEngineConfig.default, and makeWithConfig takes the settings described under Configuration:
object ProjectionEngine {
def make(specs: Projection[_]*): ZIO[Scope, Throwable, ProjectionEngine]
def makeWithConfig(config: ProjectionEngineConfig, specs: Projection[_]*): ZIO[Scope, Throwable, ProjectionEngine]
}
They check the names and folders, create a TransactorCache with TransactorCache.make(), which is closed when the enclosing Scope closes, and create the primary stores at the same paths as makeWithStoresVarargs:
val withoutEvents: ZIO[Scope, Throwable, Set[String]] =
ProjectionEngine
.makeWithConfig(ProjectionEngineConfig(batchSize = 500), projection)
.map(_.storesMap.keySet)
An engine created with make or makeWithConfig has no event stores, and there is no method to add them later. Its start logs no EventStore for source '...' - skipping for every source, so the engine neither catches up nor follows new events. It cannot rebuild a store after a schema change either: it keeps the old fingerprint. See Limitations. To run projections on SQLite files, use makeWithStores or makeWithStoresVarargs.
Companion Helpers — validateName, validateBasePath and boundedHub
The companion object also has the checks the factories run, and a helper for building a hub:
object ProjectionEngine {
def validateName(name: String): Unit
def validateBasePath(path: String): Unit
def boundedHub[E](capacity: Int = 4096): UIO[Hub[EventEnvelope[E]]]
}
validateNamethrows anIllegalArgumentExceptionunless the name is non-empty and made only of letters, digits,_and-. A projection name is also a file name, which is why it is checked.validateBasePaththrows anIllegalArgumentExceptionfor a folder path that could leave the working directory: an empty path, a backslash,..,//, or a leading/.- The factories run both on each projection:
validateNameon its name, andvalidateBasePathon the folder of itsEntityPathand on the folder of a@pathannotation on the row type. boundedHubcreates a boundedHubfor live events. A ZIOHubhands each message published to it to every subscriber, and an event store publishes each new event to its hub. When a bounded hub is full, publishing waits, so a slow consumer slows the producer down instead of losing events. The engine does not call it.
A hub from boundedHub can be given to an event store, as described under Choosing the Hub:
val smallHub: ZIO[Scope, Throwable, SQLiteEventStore[UserCreated]] = for {
cache <- TransactorCache.make()
tx <- cache.get("events/users.db")
hub <- ProjectionEngine.boundedHub[UserCreated](capacity = 256)
users <- SQLiteEventStore.makeWithHub[UserCreated](tx, hub)
} yield users
Core Operations
An engine is started once, then queried; the operations below cover starting it, reading rows back and registering a migration.
Starting the Engine — start
ProjectionEngine#start runs the projections and returns once they have caught up:
final class ProjectionEngine {
def start: ZIO[Scope, Nothing, Unit]
}
It runs these steps in order:
- Schema check. For each projection, it compares the fingerprint of the row type stored in the primary store with the current one, and brings a stale store up to date, as described under Schema Changes.
- Event store lookup. For each source of each projection, it looks for the event store under the source's name, then under
"_default", and then takes the first store in the map. So a misspelt source name does not fail: it reads another store. With no event stores at all, it logs a warning and skips the source. - Catch-up. It reads the watermark from the primary store and applies the stored events after it, read with
EventStore#readFromin batches of up tobatchSize. - Live fiber. It subscribes to the event store's
Hub, which hands every new event to each subscriber, and forks the live fiber, which applies those events as they arrive. - Pre-warm. With
lazyRebuildon, it forks one more fiber that rebuilds the pending projections in the background.
Steps 2 to 4 run for one source after another, not in parallel. start returns when every source has caught up and its live fiber is running. A source whose catch-up fails is logged with bind failed and skipped, and it gets no live fiber. With lazyRebuild, start does not wait for the pre-warm.
start has no error type: a failing source or rebuild is logged, not returned. Its fibers are forked in the Scope it runs in, so closing that Scope stops the engine. Wrap the program in ZIO.scoped, as Usage does, or run it in a ZIOAppDefault, which provides a Scope for the whole application.
The engine logs an event that fails and goes on with the next one. An event fails when:
- its handler throws;
- no handler matches it, which is logged at debug level only;
- the store rejects the action.
The watermark moves only after an event succeeds. So the next event that succeeds moves it past the failed one, and the failed event is never retried.
Nothing connects catch-up to the live fiber, and the watermark is saved in a separate write after each action. So:
- An event appended while
startis catching up is not applied. Once a later event is applied, not even a restart applies it. - An event is applied twice when catch-up reads it but its publish to the hub is delayed until after the subscription, for example by a full hub, or when the process stops between applying it and saving the watermark.
Avoid appending events while the engine starts, and prefer actions that give the same row when applied twice, such as Upsert or FieldUpdate.Set.
Reading Rows — query, queryByName and getLastProcessedSeq
Three methods read from the stores of a projection:
final class ProjectionEngine {
def query[A](spec: Projection[A], entityId: String): Task[Option[A]]
def queryByName[A](specName: String, entityId: String): Task[Option[A]]
def getLastProcessedSeq[A](spec: Projection[A]): Task[Long]
}
queryreturns the row with the IDentityId, orNone. If the projection is waiting for a lazy rebuild, the query first runs the rebuild and waits for it, and a failed rebuild fails the query. Without an event store, the projection is just unmarked, with no rebuild. The query then reads the primary store.- For a cross-entity projection only, when the primary store has no such row,
querysearches the shards this engine has opened, in no particular order, and returns the first match. The engine opens a shard when it routes an event to it, and a read error in a shard counts as no match. queryByNamereads only the primary store of the projection namedspecName, and returnsNonefor an unknown name. It neither runs a pending rebuild nor searches shards, and the typeAis not checked: the caller chooses it.getLastProcessedSeqreturns the watermark. It is kept in the primary store, also for a cross-entity projection, and0means that no event was applied yet.
Events appended after start are applied by the live fibers, so a query right after an append can still return None. Poll until the row appears. This function appends an event and asks again every 20 milliseconds until the new row is there:
def appendAndRead(engine: ProjectionEngine, users: EventStore[UserCreated]): Task[Option[UserProfile]] =
users.append("user-3", UserCreated("Carol", "carol@example.com")) *>
engine.query(projection, "user-3").delay(20.millis).repeatUntil(_.isDefined)
Registering a Migration — registerMigration
ProjectionEngine#registerMigration tells the engine how the row type of a projection changed, so that it can add columns instead of rebuilding:
final class ProjectionEngine {
def registerMigration[Old, New](spec: Projection[New], migration: Migration[Old, New]): Task[Unit]
}
Register it before start, which is where the schema check runs. The migration is kept under the projection's name, so registering another one replaces it. When it is used, and what the old rows get, is described under Adding Fields Without a Rebuild.
Configuration
ProjectionEngineConfig holds the settings of an engine, and ProjectionEngineConfig.default is the value with every default:
final case class ProjectionEngineConfig(
batchSize: Int = 100,
batchTimeout: zio.Duration = 50.millis,
ringCapacity: Int = 4096,
rebuildParallelism: Int = 4,
lazyRebuild: Boolean = false,
evolution: SchemaEvolutionConfig = SchemaEvolutionConfig.default
)
object ProjectionEngineConfig {
val default: ProjectionEngineConfig = ProjectionEngineConfig()
}
batchSizeis the most events that catch-up reads together. Each event of a batch is still applied, and its watermark saved, on its own, so the size does not change how much is applied again after a crash. Live events are processed one at a time.batchTimeoutis how long catch-up waits for a batch to fill before it applies what it has.ringCapacityis not read by the engine. OnlyTestEngine.makeWithConfigreads it, as the capacity of the hub of each in-memory source. ASQLiteEventStorecreated withmakehas its own hub of 4,096 events; to choose another, see Choosing the Hub.rebuildParallelismis the size of the groups in which the pre-warm fiber rebuilds pending projections. The groups run one after another, and so do the projections in a group, so it does not make rebuilds run in parallel. It is read only whenlazyRebuildis on.lazyRebuildmakesstartmark a projection whose schema changed instead of rebuilding it; see Rebuilding Later.evolutionis not read by the engine. In particular, itsenableMigrationShortcutis ignored: the engine always tries the shortcut when a migration is registered.
A configuration is an ordinary case class value, built with named arguments or copied from the default:
val catchUpInLargeBatches = ProjectionEngineConfig(batchSize = 1000, batchTimeout = 200.millis)
val rebuildLater = ProjectionEngineConfig.default.copy(lazyRebuild = true)
Schema Changes
On every start, before catch-up, the engine checks whether the stored rows still match the row type. It compares two fingerprints, hashes of the shape of the row type: the one saved in the primary store, and the one of the current row type. See How a Change Is Detected. What it does next depends on the result:
- No saved fingerprint, because the store is new: it saves the current one.
- Same fingerprint: nothing.
- Different fingerprint: it brings the rows up to date in one of two ways:
- with the add-column shortcut, if a migration registered for the projection only adds fields;
- otherwise with a full rebuild from the projection's event store.
- Different fingerprint, with
lazyRebuildon: it only marks the projection. The firstquery, or the pre-warm fiber, rebuilds it later.
Two limits apply; see Limitations:
- A rebuild replays only one event store: the one
startuses for the projection's first source. - An engine without event stores, made with
makeormakeWithConfig, changes nothing and keeps the old fingerprint.
Connection Management
The SQLite stores and the engine open their database files through a TransactorCache. It keeps one Transactor, the SQL module's handle on a database file, for each file path:
object TransactorCache {
def make(maxSize: Int = 256): ZIO[Scope, Nothing, TransactorCache]
def makeUnscoped(maxSize: Int = 256): UIO[TransactorCache]
def live(maxSize: Int = 256): ZLayer[Scope, Nothing, TransactorCache]
def liveConfig(config: TransactorCacheConfig): ZLayer[Scope, Nothing, TransactorCache]
}
trait TransactorCache {
def get(path: String): Task[Transactor]
def evict(path: String): Task[Unit]
def close: Task[Unit]
def size: Task[Int]
}
- Share one cache. Create it once and give it to the projection stores and the engine. Open each event store on the
Transactorthatgetreturns for its file. The Usage example does this. - Closing it.
makecloses the cache when itsScopecloses.makeUnscopedcreates one that you close yourself withclose.liveandliveConfigprovide one as aZLayer;liveConfigtakes the size as aTransactorCacheConfig(maxSize). - Its size.
maxSize, 256 by default, is how many file paths the cache keeps. When it is full, it drops the least recently used path, and the nextgetfor that file sets it up again. AmaxSizeof 0 or less means no limit.evictdrops one path, andsizereturns how many paths it keeps.
Testing
TestEngine creates an InMemoryProjectionStore for each projection and an in-memory event store for each source name, then calls ProjectionEngine.makeWithStores and start for you. The engine it wraps is available as its engine field, so a test can call the operations on this page with no files and no sqlite-jdbc. Because start runs inside TestEngine.make, a test cannot register a migration first; see Limitations.
See Also
- Projection[A] — the descriptor the engine runs: its sources, routing, handlers and actions.
- EventStore[E] — where the engine reads events from, during catch-up and live.
- ProjectionStore[A] — where the engine writes the rows and keeps the watermark.
- Migration — what
startdoes when the row type or the events change. - Testing —
TestEngine, which runs the engine on in-memory stores. - SQL Module — the
Transactorbehind the SQLite stores.