Automation — cancel expired reservations
A member reserves a book and never comes to collect it. The copy should not stay unavailable forever. Each day, a clock event prompts a reactor to find the overdue reservations and send each one through a cancellation command. No user clicks a button, and an external scheduler supplies the clock signal, so the slice is an Automation. It is shown with a C#, Kotlin, Java or TypeScript (source preview) backend; the reactor table and the series’ backend table list where they differ.
What is Automation?
Section titled “What is Automation?”In Event Modeling terms, an Automation slice answers the question: “What should the system do on its own, without a human initiating it?”
The shape is:
- A read model acts as a to-do list — it contains items that the system needs to process
- A Reactor watches the event stream
- When the reactor sees a relevant event, it evaluates the to-do list and fires a Command for each item that needs action
- That command produces another event, which closes the loop
Automation uses the same building blocks as State Change and State View. Here, Chronicle supplies event observation and Arc supplies the command pipeline through the optional Arc–Chronicle integration. Time passing is not itself an event: the scheduler must append DailyTick for the automation to run.
A daily sweep is appropriate when releasing a reservation on the next sweep is acceptable. If the Library needs cancellation at the exact expiry time, use a more frequent or per-reservation trigger. Observer retries can repeat work, so command execution and any external effects need deliberate retry handling.
Domain context
Section titled “Domain context”The Reservation slice supplies the events that open and close a reservation. The events refer to the member by MemberId, the GUID-backed member identity from Members/MemberId. This series declares it with the member registration slice in the Translation tutorial, which comes next; if you are working in order, copy that declaration now. Give the reservation and ISBN their own domain types. The reservation events belong to the same reservation event source, so their own reservation ID comes from event context. MemberId identifies a related entity and belongs in the payload.
using Cratis.Chronicle.Events;using Cratis.Concepts;using Library.Members;
namespace Library.Reservations;
// Reservations/ReservationId.cspublic record ReservationId(Guid Value) : EventSourceId<Guid>(Value){ public static readonly ReservationId NotSet = new(Guid.Empty); public static ReservationId New() => new(Guid.NewGuid()); public static implicit operator ReservationId(Guid value) => new(value);}
// Reservations/ISBN.cspublic record ISBN(string Value) : ConceptAs<string>(Value){ public static readonly ISBN NotSet = new(string.Empty); public static implicit operator ISBN(string value) => new(value);}
// Reservations/ReservationEvents.cs
/// <summary>Records a book held for a member until the collection deadline.</summary>[EventType]public record BookReserved(ISBN Isbn, MemberId MemberId, DateTimeOffset ExpiresAt);
/// <summary>Records that a reservation was canceled without collection.</summary>[EventType]public record ReservationCancelled(ISBN Isbn, MemberId MemberId);
/// <summary>Records that the member collected the reserved book.</summary>[EventType]public record BookBorrowedFromReservation(ISBN Isbn, MemberId MemberId);import io.cratis.arc.concepts.ConceptAs;import io.cratis.chronicle.events.EventType;import java.time.Instant;import java.util.UUID;
// Reservations/ReservationId.javapublic record ReservationId(UUID value) implements ConceptAs<UUID>, io.cratis.chronicle.concepts.ConceptAs<UUID> { public static final ReservationId NOT_SET = new ReservationId(new UUID(0, 0));
public static ReservationId newId() { return new ReservationId(UUID.randomUUID()); }
@Override public UUID getValue() { return value; }}
// Reservations/ISBN.javapublic record ISBN(String value) implements ConceptAs<String>, io.cratis.chronicle.concepts.ConceptAs<String> { public static final ISBN NOT_SET = new ISBN("");
@Override public String getValue() { return value; }}
// Reservations/BookReserved.java/** Records a book held for a member until the collection deadline. */@EventTypepublic record BookReserved(ISBN isbn, MemberId memberId, Instant expiresAt) { }
// Reservations/ReservationCancelled.java/** Records that a reservation was canceled without collection. */@EventTypepublic record ReservationCancelled(ISBN isbn, MemberId memberId) { }
// Reservations/BookBorrowedFromReservation.java/** Records that the member collected the reserved book. */@EventTypepublic record BookBorrowedFromReservation(ISBN isbn, MemberId memberId) { }import io.cratis.arc.concepts.ConceptAs as ArcConceptAsimport io.cratis.chronicle.concepts.ConceptAs as ChronicleConceptAsimport io.cratis.chronicle.events.EventTypeimport java.time.Instantimport java.util.UUID
// Reservations/ReservationId.ktdata class ReservationId(private val id: UUID) : ArcConceptAs<UUID>, ChronicleConceptAs<UUID> { override fun value(): UUID = id override val value: UUID get() = id
companion object { val NOT_SET = ReservationId(UUID(0, 0)) fun new(): ReservationId = ReservationId(UUID.randomUUID()) }}
// Reservations/ISBN.ktdata class ISBN(private val isbn: String) : ArcConceptAs<String>, ChronicleConceptAs<String> { override fun value(): String = isbn override val value: String get() = isbn
companion object { val NOT_SET = ISBN("") }}
// Reservations/ReservationEvents.kt
/** Records a book held for a member until the collection deadline. */@EventTypedata class BookReserved(val isbn: ISBN, val memberId: MemberId, val expiresAt: Instant)
/** Records that a reservation was canceled without collection. */@EventTypedata class ReservationCancelled(val isbn: ISBN, val memberId: MemberId)
/** Records that the member collected the reserved book. */@EventTypedata class BookBorrowedFromReservation(val isbn: ISBN, val memberId: MemberId)import { ConceptAs, field, Guid } from '@cratis/fundamentals';import { eventType } from '@cratis/chronicle/events';import { MemberId } from '../Members/MemberId.js';
// Reservations/ReservationId.tsexport class ReservationId extends ConceptAs<Guid> { static readonly valueType = Guid;
static create(): ReservationId { return new ReservationId(Guid.create()); }}
// Reservations/ISBN.tsexport class ISBN extends ConceptAs<string> { static readonly valueType = String;}
// Reservations/ReservationEvents.ts
/** Records a book held for a member until the collection deadline. */@eventType()export class BookReserved { @field(ISBN) isbn: ISBN; @field(MemberId) memberId: MemberId; @field(Date) expiresAt: Date;
constructor(isbn = new ISBN(''), memberId = new MemberId(Guid.empty), expiresAt = new Date(0)) { this.isbn = isbn; this.memberId = memberId; this.expiresAt = expiresAt; }}
/** Records that a reservation was canceled without collection. */@eventType()export class ReservationCancelled { @field(ISBN) isbn: ISBN; @field(MemberId) memberId: MemberId;
constructor(isbn = new ISBN(''), memberId = new MemberId(Guid.empty)) { this.isbn = isbn; this.memberId = memberId; }}
/** Records that the member collected the reserved book. */@eventType()export class BookBorrowedFromReservation { @field(ISBN) isbn: ISBN; @field(MemberId) memberId: MemberId;
constructor(isbn = new ISBN(''), memberId = new MemberId(Guid.empty)) { this.isbn = isbn; this.memberId = memberId; }}A BookReserved event starts the clock. If the member collects the book (BookBorrowedFromReservation), the reservation is closed. If they do not collect it before ExpiresAt, the system should cancel it and release the copy back to inventory.
Folder structure
Section titled “Folder structure”Source/└── Reservations/ └── ExpiryManagement/ └── ExpiryManagement.cs ← Read models + command + events + reactor (C# layout)Kotlin and TypeScript keep the slice in one ExpiryManagement.kt or ExpiryManagement.ts file. Java puts each public type in its own file in the same folder.
There is no .tsx file for this slice, because Automation has no UI: everything happens in the background.
Step 1 — The backend slice
Section titled “Step 1 — The backend slice”using Cratis.Arc.Commands;using Cratis.Arc.Commands.ModelBound;using Cratis.Arc.Queries.ModelBound;using Cratis.Arc.Validation;using Cratis.Chronicle.Events;using Cratis.Chronicle.Projections.ModelBound;using Cratis.Chronicle.ReadModels;using Cratis.Chronicle.Reactors;using Cratis.Monads;using MongoDB.Driver;using Library.Members;using Library.Reservations;
namespace Library.Reservations.ExpiryManagement;
// ─── Read Model ───────────────────────────────────────────────────────────────
[ReadModel][FromEvent<BookReserved>][RemovedWith<BookBorrowedFromReservation>][RemovedWith<ReservationCancelled>][RemovedWith<ReservationExpired>]public record ReservationDueForExpiry(ReservationId Id, DateTimeOffset ExpiresAt);
[ReadModel][Passive][FromEvent<BookReserved>][RemovedWith<BookBorrowedFromReservation>][RemovedWith<ReservationCancelled>][RemovedWith<ReservationExpired>]public record PendingReservation( ReservationId Id, ISBN Isbn, MemberId MemberId, DateTimeOffset ExpiresAt);
// ─── Event ────────────────────────────────────────────────────────────────────
/// <summary>Records the expiry of a reservation that was not collected in time.</summary>[EventType]public record ReservationExpired(ISBN Isbn, MemberId MemberId);
/// <summary>Records the scheduler's daily opportunity to check overdue reservations.</summary>[EventType]public record DailyTick(DateTimeOffset OccurredAt);
// ─── Command ──────────────────────────────────────────────────────────────────
[Command]public record CancelExpiredReservation(ReservationId ReservationId){ public DateTimeOffset Provide() => DateTimeOffset.UtcNow;
public Result<ReservationExpired, ValidationResult> Handle(PendingReservation? reservation, DateTimeOffset now) { if (reservation is null) { return ValidationResult.Error("There is no pending reservation to expire", [nameof(ReservationId)]); }
if (reservation.ExpiresAt > now) { return ValidationResult.Error("The reservation has not expired yet", [nameof(ReservationId)]); }
return new ReservationExpired(reservation.Isbn, reservation.MemberId); }}
// ─── Reactor ──────────────────────────────────────────────────────────────────
public class ReservationExpiryReactor( IMongoCollection<ReservationDueForExpiry> reservations, ICommandPipeline commandPipeline) : IReactor{ [OnceOnly] public async Task HandleDailyTick(DailyTick @event) { var expired = await reservations .Find(reservation => reservation.ExpiresAt <= @event.OccurredAt) .ToListAsync();
foreach (var reservation in expired) { var result = await commandPipeline.Execute( new CancelExpiredReservation(reservation.Id)); // A rejected command means the reservation was already handled or is no longer due. if (!result.IsAuthorized || result.HasExceptions) { throw new ReservationExpiryFailed(reservation.Id); } } }}
public class ReservationExpiryFailed(ReservationId reservationId) : Exception($"Could not expire reservation '{reservationId}'.");import io.cratis.arc.artifacts.Command;import io.cratis.arc.artifacts.CommandKey;import io.cratis.arc.authorization.AllowAnonymous;import io.cratis.arc.chronicle.ChronicleCommandSideEffectHandler;import io.cratis.arc.results.CommandResult;import io.cratis.chronicle.events.EventContext;import io.cratis.chronicle.events.EventType;import io.cratis.chronicle.observation.OnceOnly;import io.cratis.chronicle.observation.Reactor;import io.cratis.chronicle.projections.FromEvent;import io.cratis.chronicle.projections.FromEventSourceId;import io.cratis.chronicle.projections.RemovedWith;import io.cratis.chronicle.readModels.Passive;import io.cratis.chronicle.readModels.ReadModel;import io.cratis.chronicle.spring.Chronicle;import java.time.Instant;import java.util.Optional;import java.util.UUID;
// ─── Read Models ──────────────────────────────────────────────────────────────
// Reservations/ExpiryManagement/ReservationDueForExpiry.java@ReadModel@FromEvent(eventType = BookReserved.class)@RemovedWith(eventType = BookBorrowedFromReservation.class)@RemovedWith(eventType = ReservationCancelled.class)@RemovedWith(eventType = ReservationExpired.class)public class ReservationDueForExpiry { @FromEventSourceId public String id = ""; public Instant expiresAt = Instant.EPOCH;}
// Reservations/ExpiryManagement/PendingReservation.java@ReadModel@Passive@FromEvent(eventType = BookReserved.class)@RemovedWith(eventType = BookBorrowedFromReservation.class)@RemovedWith(eventType = ReservationCancelled.class)@RemovedWith(eventType = ReservationExpired.class)public class PendingReservation { public ISBN isbn = ISBN.NOT_SET; public MemberId memberId = MemberId.NOT_SET; public Instant expiresAt = Instant.EPOCH;}
// ─── Events ───────────────────────────────────────────────────────────────────
// Reservations/ExpiryManagement/ReservationExpired.java/** Records the expiry of a reservation that was not collected in time. */@EventTypepublic record ReservationExpired(ISBN isbn, MemberId memberId) { }
// Reservations/ExpiryManagement/DailyTick.java/** Records the scheduler's daily opportunity to check overdue reservations. */@EventTypepublic record DailyTick(Instant occurredAt) { }
// ─── Command ──────────────────────────────────────────────────────────────────
// Reservations/ExpiryManagement/CancelExpiredReservation.java@Command@AllowAnonymouspublic record CancelExpiredReservation(@CommandKey ReservationId reservationId) { public Instant provide() { return Instant.now(); }
public ReservationExpired handle(Instant now, Optional<PendingReservation> reservation) { if (reservation.isEmpty() || reservation.get().expiresAt.isAfter(now)) { return null; }
return new ReservationExpired(reservation.get().isbn, reservation.get().memberId); }}
// ─── Reactor ──────────────────────────────────────────────────────────────────
// Reservations/ExpiryManagement/ReservationExpiryReactor.java@Reactorpublic class ReservationExpiryReactor { private final Chronicle chronicle; private final ChronicleCommandSideEffectHandler commands;
public ReservationExpiryReactor(Chronicle chronicle, ChronicleCommandSideEffectHandler commands) { this.chronicle = chronicle; this.commands = commands; }
@OnceOnly public void dailyTick(DailyTick event, EventContext context) { var expired = chronicle.readModels(ReservationDueForExpiry.class).stream() .filter(reservation -> !reservation.expiresAt.isAfter(event.occurredAt())) .toList();
for (var reservation : expired) { var reservationId = new ReservationId(UUID.fromString(reservation.id)); CommandResult<?> result = commands .executeAsync(new CancelExpiredReservation(reservationId), ReservationExpiryReactor.class, context) .toCompletableFuture() .join(); if (!result.isSuccess()) { throw new ReservationExpiryFailed(reservationId); } } }}
// Reservations/ExpiryManagement/ReservationExpiryFailed.javapublic class ReservationExpiryFailed extends RuntimeException { public ReservationExpiryFailed(ReservationId reservationId) { super("Could not expire reservation '" + reservationId.value() + "'."); }}import io.cratis.arc.artifacts.Commandimport io.cratis.arc.artifacts.CommandKeyimport io.cratis.arc.authorization.AllowAnonymousimport io.cratis.arc.chronicle.ChronicleCommandSideEffectHandlerimport io.cratis.chronicle.IEventStoreimport io.cratis.chronicle.events.EventContextimport io.cratis.chronicle.events.EventTypeimport io.cratis.chronicle.observation.OnceOnlyimport io.cratis.chronicle.observation.Reactorimport io.cratis.chronicle.projections.FromEventimport io.cratis.chronicle.projections.FromEventSourceIdimport io.cratis.chronicle.projections.RemovedWithimport io.cratis.chronicle.readModels.Passiveimport io.cratis.chronicle.readModels.ReadModelimport java.time.Instantimport java.util.UUID
// Reservations/ExpiryManagement/ExpiryManagement.kt
// ─── Read Models ──────────────────────────────────────────────────────────────
@ReadModel@FromEvent(BookReserved::class)@RemovedWith(BookBorrowedFromReservation::class)@RemovedWith(ReservationCancelled::class)@RemovedWith(ReservationExpired::class)data class ReservationDueForExpiry( @FromEventSourceId val id: String = "", val expiresAt: Instant = Instant.EPOCH)
@ReadModel@Passive@FromEvent(BookReserved::class)@RemovedWith(BookBorrowedFromReservation::class)@RemovedWith(ReservationCancelled::class)@RemovedWith(ReservationExpired::class)data class PendingReservation( val isbn: ISBN = ISBN.NOT_SET, val memberId: MemberId = MemberId.NOT_SET, val expiresAt: Instant = Instant.EPOCH)
// ─── Events ───────────────────────────────────────────────────────────────────
/** Records the expiry of a reservation that was not collected in time. */@EventTypedata class ReservationExpired(val isbn: ISBN, val memberId: MemberId)
/** Records the scheduler's daily opportunity to check overdue reservations. */@EventTypedata class DailyTick(val occurredAt: Instant)
// ─── Command ──────────────────────────────────────────────────────────────────
@Command@AllowAnonymousdata class CancelExpiredReservation(@CommandKey val reservationId: ReservationId) { fun provide(): Instant = Instant.now()
fun handle(now: Instant, reservation: PendingReservation?): ReservationExpired? { if (reservation == null || reservation.expiresAt > now) { return null }
return ReservationExpired(reservation.isbn, reservation.memberId) }}
// ─── Reactor ──────────────────────────────────────────────────────────────────
@Reactorclass ReservationExpiryReactor( private val eventStore: IEventStore, private val commands: ChronicleCommandSideEffectHandler) { @OnceOnly suspend fun dailyTick(event: DailyTick, context: EventContext) { val expired = eventStore.readModels.getInstances(ReservationDueForExpiry::class) .filter { reservation -> reservation.expiresAt <= event.occurredAt }
for (reservation in expired) { val reservationId = ReservationId(UUID.fromString(reservation.id)) val result = commands.execute( CancelExpiredReservation(reservationId), ReservationExpiryReactor::class.java, context ) if (!result.isSuccess) { throw ReservationExpiryFailed(reservationId) } } }}
class ReservationExpiryFailed(reservationId: ReservationId) : Exception("Could not expire reservation '${reservationId.value()}'.")import { field, Guid } from '@cratis/fundamentals';import { command, commandReadModel, inject, key, readModel } from '@cratis/arc.core';import { eventType, type EventContext } from '@cratis/chronicle/events';import { fromEvent, passive, removedWith } from '@cratis/chronicle/projections';import { onceOnly, reactor, type ReactorServices } from '@cratis/chronicle/reactors';import { MemberId } from '../../Members/MemberId.js';import { ISBN } from '../ISBN.js';import { BookBorrowedFromReservation, BookReserved, ReservationCancelled } from '../ReservationEvents.js';import { ReservationId } from '../ReservationId.js';
// Reservations/ExpiryManagement/ExpiryManagement.ts// Events come first: a decorator can only reference a class that is already declared.
// ─── Events ───────────────────────────────────────────────────────────────────
/** Records the expiry of a reservation that was not collected in time. */@eventType()export class ReservationExpired { @field(ISBN) isbn: ISBN; @field(MemberId) memberId: MemberId;
constructor(isbn = new ISBN(''), memberId = new MemberId(Guid.empty)) { this.isbn = isbn; this.memberId = memberId; }}
/** Records the scheduler's daily opportunity to check overdue reservations. */@eventType()export class DailyTick { @field(Date) occurredAt: Date;
constructor(occurredAt = new Date(0)) { this.occurredAt = occurredAt; }}
// ─── Read Models ──────────────────────────────────────────────────────────────
@readModel()@fromEvent(BookReserved)@removedWith(BookBorrowedFromReservation)@removedWith(ReservationCancelled)@removedWith(ReservationExpired)export class ReservationDueForExpiry { @field(ReservationId) id!: ReservationId; @field(Date) expiresAt!: Date;}
@readModel()@passive@fromEvent(BookReserved)@removedWith(BookBorrowedFromReservation)@removedWith(ReservationCancelled)@removedWith(ReservationExpired)export class PendingReservation { @field(ReservationId) id!: ReservationId; @field(ISBN) isbn!: ISBN; @field(MemberId) memberId!: MemberId; @field(Date) expiresAt!: Date;}
// ─── Command ──────────────────────────────────────────────────────────────────
@command()export class CancelExpiredReservation { @key() @field(ReservationId) reservationId: ReservationId;
constructor(reservationId = new ReservationId(Guid.empty)) { this.reservationId = reservationId; }
provide(): Date { return new Date(); }
@inject(commandReadModel(PendingReservation, { optional: true })) handle(now: Date, reservation: PendingReservation | null): ReservationExpired | undefined { if (!reservation || reservation.expiresAt > now) { return undefined; }
return new ReservationExpired(reservation.isbn, reservation.memberId); }}
// ─── Reactor ──────────────────────────────────────────────────────────────────
@reactor()export class ReservationExpiryReactor { @onceOnly() async dailyTick(event: DailyTick, _context: EventContext, services: ReactorServices): Promise<CancelExpiredReservation[]> { // The event arrives as parsed JSON, so its date is a string until converted. const occurredAt = new Date(event.occurredAt); const reservations = await services.readModels.getInstances(ReservationDueForExpiry);
return reservations .filter(reservation => reservation.expiresAt <= occurredAt) .map(reservation => new CancelExpiredReservation(reservation.id)); }}What is happening here?
Section titled “What is happening here?”The passive read model PendingReservation exists only to support decisions inside the cancellation command; the frontend is not meant to query it. Because it is passive, Chronicle does not proactively observe and replay the projection. Chronicle computes it on demand, by the reservation’s key, when the command asks for it. It follows BookReserved for matching properties and is removed by each of the three closing events, ReservationExpired among them.
The to-do list ReservationDueForExpiry is the active, stored read model the reactor sweeps; the reactor never reads the passive model. Every backend projects it from BookReserved and removes it with each closing event, like the passive model. C# reads it from its MongoDB collection, TypeScript through the reactor’s services.readModels, and Kotlin and Java through Chronicle’s read models.
The sweep needs each reservation’s ID in the model; Kotlin and Java mark id with @FromEventSourceId so the projection copies the event-source ID into it.
The cancellation command CancelExpiredReservation provides the current time before its handler runs, so you can also test the decision directly with a fixed timestamp. The handler asks for the pending reservation by the command’s ReservationId, and the Arc–Chronicle integration resolves that projection on demand. The handler accepts a missing reservation — a nullable parameter in C# and Kotlin, commandReadModel(PendingReservation, { optional: true }) in TypeScript, and Optional in Java. A missing, collected, canceled, or already expired reservation produces no new event; a reservation whose deadline has passed produces ReservationExpired.
The sweep list can lag behind the event log, so it only nominates candidates. The command rechecks each reservation when it runs, without trusting the earlier collection query, and decides whether it may expire. Read-model injection alone is not a blanket guarantee against a concurrent collection or cancellation; a production lifecycle needs append-time rules or the applicable consistency controls for competing terminal transitions.
The reactor runs its daily-tick handler when Chronicle delivers a DailyTick. Configure a scheduler or background service to append that event to the application’s event log; the reactor does not create its own timer. If the to-do projection has not caught up at a sweep, a later tick can pick up the remaining reservation.
Each cancellation goes through Arc’s command pipeline, so validation and authorization apply exactly as they would for an HTTP caller. A failed command must fail the handler, so Chronicle does not acknowledge the tick as successfully handled.
The reactor has no original HTTP principal. The C# and TypeScript commands here have no authorization rules, and the Kotlin and Java commands are marked @AllowAnonymous to match. If these commands require authorization, configure a deliberate execution context: in Kotlin, Java and TypeScript, the reactor’s @ExecuteCommandsAsSystem or @executeCommandsAsSystem marker gives its commands a system principal with the roles you list.
How each backend runs the reactor
Section titled “How each backend runs the reactor”This is the first reactor in the series; the Translation tutorial uses the same mechanics.
| Mechanism | C# | Kotlin and Java | TypeScript (preview) |
|---|---|---|---|
| Reactor | An IReactor | A @Reactor class | A @reactor() class |
| How Chronicle finds the handler | Supported signatures and event parameter types | The event parameter type | The method name: the event class in camelCase (dailyTick for DailyTick) |
| How the commands run | The reactor calls the constructor-injected ICommandPipeline | The reactor passes each command, its own type and the event context to Arc’s ChronicleCommandSideEffectHandler | The handler returns the commands, and Arc’s reactorCommandResultHandler runs them in order |
| How a failed command fails the handler | Inspect IsSuccess and throw the named exception | Check isSuccess and throw the named exception | The result handler stops at the first failure and fails the handler |
| Once-only marker (Step 2) | [OnceOnly] | @OnceOnly | @onceOnly() |
In C#, a failed command may return a result without throwing, so the reactor inspects IsSuccess and throws the named exception to make the failure visible to Chronicle. The TypeScript handler receives the stored event as parsed JSON, so it converts occurredAt back to a Date. withChronicle installs reactorCommandResultHandler for a client Arc creates; a Chronicle client you create yourself needs it as its reactorResultHandler option.
Step 2 — Idempotency with once-only handlers
Section titled “Step 2 — Idempotency with once-only handlers”The once-only marker on the daily-tick handler skips the method during replay. Rebuilding projections should not execute today’s cancellation logic for every historical clock tick. The projections still consume the recorded ReservationExpired events to rebuild their state.
The marker is a replay policy, not exactly-once delivery: a failure can cause the handler to run again after earlier commands succeeded. A later command sees the recorded expiry and returns no event, provided the reservation has already reached that terminal state. External effects, such as releasing a copy through another service, need their own stable idempotency key or equivalent duplicate handling.
Step 3 — Error handling
Section titled “Step 3 — Error handling”If the daily-tick handler fails, Chronicle’s observer failure and recovery policy applies. Earlier successful cancellation commands are not rolled back as a group. Monitor failed partitions and repair or retry the underlying operation; do not swallow the failure.
Design your reactor methods to be safe:
- Treat an already closed reservation as a successful no-op, not an exceptional failure
- Return explicit validation results for actual business rejection and fail the handler on unsuccessful command results
- Use the once-only marker to control replay, and design retries separately
- Test missing reservations, deadlines before/at/after
now, and all three closing events; then exercise the scheduler, projection catch-up, and retry path together in a hosted test