Skip to content

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; }
}

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());
}
}
}

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.

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);
}
}

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.

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.

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) {}
@Test
void 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();
}

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.

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