Skip to content

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.

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:

  1. A read model acts as a to-do list — it contains items that the system needs to process
  2. A Reactor watches the event stream
  3. When the reactor sees a relevant event, it evaluates the to-do list and fires a Command for each item that needs action
  4. 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.

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.cs
public 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.cs
public 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);

View C# snippet source on GitHub

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.

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.

Reservations/ExpiryManagement/ExpiryManagement.cs
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}'.");

View C# snippet source on GitHub

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.

This is the first reactor in the series; the Translation tutorial uses the same mechanics.

MechanismC#Kotlin and JavaTypeScript (preview)
ReactorAn IReactorA @Reactor classA @reactor() class
How Chronicle finds the handlerSupported signatures and event parameter typesThe event parameter typeThe method name: the event class in camelCase (dailyTick for DailyTick)
How the commands runThe reactor calls the constructor-injected ICommandPipelineThe reactor passes each command, its own type and the event context to Arc’s ChronicleCommandSideEffectHandlerThe handler returns the commands, and Arc’s reactorCommandResultHandler runs them in order
How a failed command fails the handlerInspect IsSuccess and throw the named exceptionCheck isSuccess and throw the named exceptionThe 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.

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