Reactor Catalogue

1. Overview

This page is the reactor-specific companion to Signal & Sweep Framework Architecture. The architecture page describes the machinery; this page records what we actually run on it — which signal types exist, what each aggregate key means, and which reactors consume what.

Read the architecture page first. Nothing here makes sense without the concepts of signal, reactor, sweep and squash.

2. Delivery Status

The framework is being delivered in phases under Feature #403. Most of the catalogue below is planned, not built. Check this table before assuming a reactor exists.

Reactor Status Notes

SignalRetentionReactor

Phase 1

Framework-internal. Purges expired signals; validates the sweep path with no domain risk.

CommunicationReactor

Phase 3

First functional target. Order-level email on registration and payment.

LeaderboardReactor

Deferred

Migration blocked — see LeaderboardReactor — deferred.

All others

Not started

Each arrives with its parent design thread.

3. Signal Types

Signal type Emitted when Consumed by Debounce

RESULT_SET_CHANGED

Race results added, modified or deleted

LeaderboardReactor

60s

REGISTRATION_COMPLETED

Registration process completes and a draft Order is created

CommunicationReactor, SeedingReactor

0s

PAYMENT_COMPLETED

Order transitions to PAID

CommunicationReactor

0s

PAYMENT_FAILED

Payment gateway reports failure

CommunicationReactor

0s

PERSON_MERGED

A person merge completes

LeaderboardReactor, NumberTagReactor

60s

PARTICIPANT_CATEGORY_CHANGED

EventParticipant.category_id modified

LeaderboardReactor, SeedingReactor

15m

MEMBERSHIP_ACTIVATED

Membership transitions to ACTIVE

CommunicationReactor

0s

ORDER_STATUS_CHANGED

Any order status transition

CommunicationReactor

0s

Communication signals use a zero debounce deliberately. Each occurrence corresponds to one message to one customer; squashing two of them would silently drop a communication. Recalculation signals use a non-zero window because the work is expensive and the result only depends on final state.

3.1. Why there is no ENTITY_MODIFIED

A generic "some entity changed" signal was designed and then removed. A bulk import of 500 participants would emit 500 signals; even squashed by entity type, the table becomes noise, and the remote APIs that would consume it are better served by batched updates. Change detection for sync already exists as timestamp comparison, which is a sweep by another name. See DataSyncReactor — sweep-only.

If a future integration genuinely needs near-real-time push, add a target-specific signal type rather than resurrecting a generic one.

4. Aggregate Key Conventions

The aggregate key defines what "the same work" means for squashing. Get it wrong and you either lose signals or fail to coalesce them.

Pattern Meaning

series:{id}

Work is per series. All result changes anywhere in the series collapse into one recalculation.

order:{id}

Work is per order. Used for registration and payment communications.

participant:{id}

Work is per event participant.

event:{id}

Work is per event — used where a reactor processes a whole event’s participants in one pass.

Two rules:

  1. Key at the granularity the reactor actually works at. If the reactor recalculates a whole series, keying by participant produces N redundant recalculations of the same series.

  2. The key must be stable for the duration of the debounce window. Keys derived from mutable state can split what should have been one unit of work.

A signal type consumed by two reactors that work at different granularities is a design smell. Either split the signal type, or key at the coarser granularity and let the finer reactor narrow it down.

5. Reactor Catalogue

Reactor Signal types Debounce key Sweep Style

SignalRetentionReactor

(none)

Nightly

Direct

LeaderboardReactor

RESULT_SET_CHANGED, PERSON_MERGED, PARTICIPANT_CATEGORY_CHANGED

series:{id}

Every 10 min

Direct

CommunicationReactor

REGISTRATION_COMPLETED, PAYMENT_COMPLETED, PAYMENT_FAILED, MEMBERSHIP_ACTIVATED, ORDER_STATUS_CHANGED

order:{id}

Every 15 min (reminders)

Direct, then Fluxnova

DataSyncReactor

(none — sweep-only)

Every 5 min

Direct

OrderPurgeReactor

(none — sweep-only)

Daily 03:00

Direct

SeedingReactor

REGISTRATION_COMPLETED, PARTICIPANT_CATEGORY_CHANGED

event:{id}

Daily 02:30

Direct

NumberTagReactor

PERSON_MERGED

participant:{id}

Within DataSync sweep

Direct

NumberReassignmentReactor

(none — sweep-only)

5 min dev / 15 min prod

Direct

IdentityRefreshReactor

(none — sweep-only)

Daily 02:00

Direct

ReconciliationReactor

(none — sweep-only)

Weekly Sun 04:00

Direct

Sweep ordering is expressed through cron times rather than an explicit dependency graph. IdentityRefreshReactor at 02:00 precedes SeedingReactor at 02:30 because seeding depends on refreshed identity data. This is sufficient while the catalogue is small; a real dependency graph would be premature.

6. Reactor Notes

6.1. CommunicationReactor

Handles registration and payment communications. Two decisions shape it:

Order-level, not participant-level. One email per order, addressed to the billing email. An order with seventeen participants sends one message, not seventeen to the same inbox. Order and participant emails carry genuinely different content — an order email is buyer-facing (reference, total, payment status, one pay link, a summary of who is entered), a participant email is athlete-facing (their category, race number, start group, event-day logistics). They are separate templates, not one template with a different recipient. Per-participant email is a later addition.

Direct, not via Fluxnova — for now. The framework routes single-step reactions in-process and reserves Fluxnova for multi-step orchestration. Order confirmation is single-step, and Fluxnova remains deployed but unconfigured, so the reactor calls the email service directly. Multi-step escalation — a payment reminder sequence with channel fallback — remains the Fluxnova case and will be added as a separate path rather than by rewriting this one.

Idempotency is a hard prerequisite. A reactor that sends email will be retried by the framework after a transient SMTP failure. Without a dedup guard in communication_log, that retry sends a second real email to a real customer. The guard must be in place before this reactor is enabled anywhere. See Email Service.

6.2. DataSyncReactor — sweep-only

The bi-directional sync framework already detects change by comparing lastModifiedMain and lastModifiedRemote timestamps. That is a sweep. Adding reactive signals on top would duplicate the detection, flood the signal table during bulk imports, and turn efficient batch API calls into chatty per-entity ones.

The sweep runs every five minutes, collects everything changed since the last watermark, and submits it in one batch. See Data Synchronisation.

6.3. NumberReassignmentReactor

Closes the loop on the DETACH-and-reconcile cascade introduced by Feature #473. When an admin reassigns a race number, the system nulls number_id on the same participant’s future same-type event participants. Those sit unassigned until something reconciles them — this reactor.

It selects event participants with a null number on future events whose event type requires one, then calls the pool service per participant in its own transaction, so one failure does not block the rest. Assignment obeys ADR-0006: prefer the participant’s latest used number of that subtype, else draw from stock; if their latest is UNFIT_FOR_SERVICE, leave it unassigned and surface it as a validation error rather than auto-assigning.

Until this reactor exists, reconciliation is manual via the event-finalisation gate.

6.4. LeaderboardReactor — deferred

The existing LeaderboardSynchronizationService was the intended first migration: it already implements the signal-plus-poll pattern, using an in-memory map that does not survive restarts. Moving it onto the framework would give it durability, retry and visibility.

That migration is deferred. Its core operation — upserting calculated entries into a ResultSet — is still an unimplemented TODO, so the service currently computes leaderboard entries and discards them. Migrating it would validate nothing about the framework and would move a half-finished feature onto new foundations.

Until then, ResultSetChangedEvent and LeaderboardSynchronizationService stay exactly as they are. Where RESULT_SET_CHANGED signals are emitted, they are emitted alongside the existing Spring ApplicationEvent, not instead of it, so leaderboard behaviour is unchanged.

7. Multi-Reactor Failure

When two reactors consume the same signal and one fails, per-reactor delivery tracking means only the failure is retried.

Person merged, tags fail to reassign

PERSON_MERGED is consumed by both LeaderboardReactor and NumberTagReactor.

Attempt LeaderboardReactor NumberTagReactor

1

COMPLETED — leaderboards updated

FAILED — database timeout on the tag table

2

skipped, already COMPLETED

COMPLETED

The signal completes after attempt 2. The leaderboard was recalculated exactly once.

Category changed, seeding hits a data fault
Attempt LeaderboardReactor SeedingReactor

1

COMPLETED

FAILED — series category mapping missing

2–4

skipped

FAILED, then DEAD

The signal is marked COMPLETED with SeedingReactor DEAD and visible in the admin API. The leaderboard ran once, not four times.

The lesson for reactor authors: your reactor’s failure is yours alone. Do not throw to signal a problem in someone else’s concern, and do not assume a retry means you failed.