Model schedule reconciliation
A user moves a reminder from Monday to Tuesday. Your background consumer may still receive Monday’s older event. Reconciliation makes it look at the reminder’s current intent and schedule Tuesday’s work, instead of restoring a stale deadline. Use it when delayed work must follow Model status or deadline changes.
For deletion-only cleanup, parent-owned schedules offer a simpler contract:
@Parent on the scheduled payload cancels it when that committed Model is deleted, including an applicable
@Apply returning null or a cascade. Reconciliation below remains useful for status and deadline changes,
and delivered commands still need guards.
For delayed work derived directly from Model state, see declarative Model deadlines. They combine intent changes and stale-delivery guards with the Model commit.
Persist intent, then reconcile the current Model
Section titled “Persist intent, then reconcile the current Model”Start with ordinary event-sourced Models. A reminder has its own lifecycle and belongs to a project through @Parent;
deleting the project also deletes the reminder. The deadline and completion flag are execution state, not descriptive
details. ID types below extend Id<Project> and Id<Reminder>.
@Model record Project(@EntityId ProjectId projectId) {}@Model record Reminder(@EntityId ReminderId reminderId, @Parent ProjectId projectId, Instant deadline, boolean completed) {}
record PlanReminder(ReminderId reminderId, ProjectId projectId, Instant deadline) { @Apply Reminder apply() { return new Reminder(reminderId, projectId, deadline, false); }}record RescheduleReminder(ReminderId reminderId, Instant deadline) { @Apply Reminder apply(Reminder current) { return new Reminder(reminderId, current.projectId(), deadline, false); }}record DeleteProject(ProjectId projectId) { @Apply Project apply(Project current) { return null; }}@Model data class Project(@EntityId val projectId: ProjectId)@Model data class Reminder( @EntityId val reminderId: ReminderId, @Parent val projectId: ProjectId, val deadline: Instant, val completed: Boolean)
data class PlanReminder(val reminderId: ReminderId, val projectId: ProjectId, val deadline: Instant) { @Apply fun apply() = Reminder(reminderId, projectId, deadline, false)}data class RescheduleReminder(val reminderId: ReminderId, val deadline: Instant) { @Apply fun apply(current: Reminder) = current.copy(deadline = deadline, completed = false)}data class DeleteProject(val projectId: ProjectId) { @Apply fun apply(current: Project): Project? = null}A registered tracked post-commit consumer reconciles the desired active schedule. One unqualified Graph<Reminder> parameter
receives direct changes and cascaded deletion. No parent-specific cleanup handler is needed for new commits that
record cascade notifications.
@Consumer(name = "reminder-schedules", singleTracker = true, errorHandler = ForeverRetryingErrorHandler.class)final class ReminderSchedules { @HandleEvent void changed(Graph<Reminder> change) { Reminder current = Fluxzero.loadCurrentGraph(change.id(), Reminder.class, false).get(); ScheduleId id = ScheduleId.of("reminder", change.id()); if (current == null || current.completed()) { Fluxzero.cancelSchedule(id); } else { Fluxzero.scheduleCommand(new RunReminder(current.reminderId(), current.deadline()), id, current.deadline()); } }}@Consumer(name = "reminder-schedules", singleTracker = true, errorHandler = ForeverRetryingErrorHandler::class)class ReminderSchedules { @HandleEvent fun changed(change: Graph<Reminder>) { val current = Fluxzero.loadCurrentGraph(change.id(), Reminder::class.java, false).get() val id = ScheduleId.of("reminder", change.id()) if (current == null || current.completed) { Fluxzero.cancelSchedule(id) } else { Fluxzero.scheduleCommand(RunReminder(current.reminderId, current.deadline), id, current.deadline) } }}Register this consumer with the application. singleTracker = true serializes its reconciliation work, including
parent-triggered deletion and direct reminder events that may have different routing segments. Do not independently
write the same schedule from another consumer. Stable IDs make replacement and cancellation repeatable.
This consumer deliberately uses ForeverRetryingErrorHandler so a recoverable scheduling outage delays its work.
Keep awaitOutgoingWrites = true (the default), monitor lag and repair permanent failures. An initial functional
failure is excluded from retries by default. See consumer error handling
for retry and stop policies.
The false argument uses the already affixed repository identity from change.id() without adding affixes again;
alias resolution remains enabled. When you already have the Graph, change.current().get() is the shorter alternative
and also preserves its owning repository and namespace.
The injected graph remains pinned to the triggering event. loadCurrentGraph deliberately escapes that historical
boundary: redelivering an old creation event must not restore a deleted reminder or an obsolete deadline. This is a
normal use of current reads, not restricted to synchronous nested commands. Schedule writes occur after commit, not
inside @Apply or @AssertLegal. They are an eventually reconciled effect, not part of the Model transaction.
Guard the delivered command too
Section titled “Guard the delivered command too”Cancellation cannot recall a command already delivered. An early or stale command must recheck current intent before
applying. Keep clock-dependent decisions in evaluation, not in replayed @Apply logic:
record RunReminder(ReminderId reminderId, Instant expectedDeadline) { @InterceptApply RunReminder applicable(@Nullable Reminder current) { return current == null || current.completed() || !current.deadline().equals(expectedDeadline) || Fluxzero.currentTime().isBefore(expectedDeadline) ? null : this; } @Apply Reminder apply(Reminder current) { return new Reminder(reminderId, current.projectId(), current.deadline(), true); }}data class RunReminder(val reminderId: ReminderId, val expectedDeadline: Instant) { @InterceptApply fun applicable(current: Reminder?): RunReminder? = if (current == null || current.completed || current.deadline != expectedDeadline || Fluxzero.currentTime().isBefore(expectedDeadline)) null else this
@Apply fun apply(current: Reminder) = current.copy(completed = true)}This example marks the work completed; any external side effect still needs its own retry/idempotency contract. If a logical reminder ID can be deleted and recreated with the same deadline, add a generation token to its persisted intent and command and check that too. A deadline is not a universal identity for an execution.
Know what ifAbsent protects
Section titled “Know what ifAbsent protects”The example uses replacement semantics: the current Model’s deadline is authoritative. ifAbsent = true would retain
an existing active schedule with an obsolete deadline. It is useful only when an unchanged schedule should be preserved,
and is not a historical once-only marker. After cancellation, an old event could create the ID again unless current
state prevents creation.
Assert the complete active command set
Section titled “Assert the complete active command set”expectOnlyScheduledCommands(...) checks only commands dispatched for scheduling during When. It can pass while an older
schedule is still active. The local fixture’s expectOnlyActiveScheduledCommands(...) checks all active scheduled
commands and unwraps their payloads. Use a Predicate<Schedule> to include ID, deadline and command contents:
record RunTask(String taskId, Instant expectedDeadline) {}
@Testvoid replacementLeavesExactlyOneActiveCommand() { Instant start = Instant.parse("2026-01-01T00:00:00Z"); Instant oldDeadline = start.plusSeconds(3600); Instant deadline = oldDeadline.plusSeconds(600); RunTask command = new RunTask("task-1", deadline);
TestFixture.create().atFixedTime(start) .givenScheduledCommands(new Schedule(new RunTask("task-1", oldDeadline), "task-1", oldDeadline)) .whenExecuting(fc -> Fluxzero.scheduleCommand(command, "task-1", deadline)) .expectOnlyActiveScheduledCommands((Predicate<Schedule>) s -> s.getScheduleId().equals("task-1") && s.getDeadline().equals(deadline) && s.getPayload().equals(command)) .andThen().whenExecuting(fc -> Fluxzero.cancelSchedule("task-1")) .expectOnlyActiveScheduledCommands().expectNoSchedules();}data class RunTask(val taskId: String, val expectedDeadline: Instant)
@Testfun replacementLeavesExactlyOneActiveCommand() { val start = Instant.parse("2026-01-01T00:00:00Z") val oldDeadline = start.plusSeconds(3600) val deadline = oldDeadline.plusSeconds(600) val command = RunTask("task-1", deadline)
TestFixture.create().atFixedTime(start) .givenScheduledCommands(Schedule(RunTask("task-1", oldDeadline), "task-1", oldDeadline)) .whenExecuting { Fluxzero.scheduleCommand(command, "task-1", deadline) } .expectOnlyActiveScheduledCommands(Predicate<Schedule> { it.scheduleId == "task-1" && it.deadline == deadline && it.getPayload<Any>() == command }) .andThen().whenExecuting { Fluxzero.cancelSchedule("task-1") } .expectOnlyActiveScheduledCommands().expectNoSchedules()}If replacement accidentally uses another ID, the assertion fails because both commands remain active.
In an application test, replace whenExecuting with the real reschedule/delete command and register its reconciliation
handler; the assertion stays the same. Run the scenario asynchronously too when tracked handlers own reconciliation.
A plain Schedule expectation does not compare ID; the predicate does.
This new assertion excludes ordinary @HandleSchedule payloads. Use expectNoSchedules() for absence of both kinds.
It requires a local scheduling client and fails explicitly for remote clients: observing remote writes is not a
complete inventory. For a retained remote store, verify known IDs through messageScheduler().getSchedule(id)
and do not claim that checks of known IDs discover every unknown stale ID.
Lifecycle test matrix
Section titled “Lifecycle test matrix”Test creation, deadline replacement, completion, direct deletion and cascade deletion. Reconcile an old change against
newer current state: it must not restore obsolete work. Exercise stale and early delivered commands, and retry after a
schedule-operation failure. Use exact active-ID/deadline assertions; expectNoNewSchedules() alone does not prove that
an obsolete schedule is absent.
Keep reconstruction claims separate. givenScheduledCommands(...) and givenSchedules(...) supply scheduler state;
they do not prove another application instance recovered persisted schedules. Ordinary Model event reconstruction
requires EVENT_SOURCED; DOCUMENT alone keeps no previous versions. In particular, to use previous() for
historical values, keep event sourcing enabled. Current-intent reconciliation above does not need previous().
© 2026 Fluxzero