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

Phase 4

Built under US #409. Replaces `LeaderboardSynchronizationService’s in-memory debounce — see LeaderboardReactor.

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. RESULT_SET_CHANGED uses it only for a result set with no event, whose change recalculates every event of the series.

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 in one pass. RESULT_SET_CHANGED uses it: a result change recalculates that event’s leaderboard standings.

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 and PARTICIPANT_CATEGORY_CHANGED once they have emitters

event:{id}

Every 10 min — retries FAILED drafts

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

Recalculates leaderboard standings when race results change, and retries the ones whose last recalculation failed. Built under US #409.

What it replaced. LeaderboardSynchronizationService listened for a Spring ResultSetChangedEvent and debounced it in an in-memory map drained by a @Scheduled poll on every pod. A restart lost whatever was pending, and a failure was logged and forgotten. The migration was deferred until the standings were actually persisted (#1379–#1381); until then, a recalculation computed a standing and discarded it, so moving it onto the framework would have validated nothing. ResultSetChangedEvent and LeaderboardFailureNotificationService are retired; the signal is the only trigger.

Keyed per event, not per series. The catalogue originally keyed RESULT_SET_CHANGED on series:{id}. A squash keeps the first signal’s entity and replaces its payload, so under a series key the reactor cannot tell which events changed and would have to recalculate every one. That restates every published standing of the series on any change anywhere in it, and clones every approved one — the reason recalculation became per-event in #1380. Keyed on event:{id}, a bulk import still squashes to one signal, because an import covers one event.

Failure. Each leaderboard is recalculated in its own transaction. One that fails writes nothing, and the others still commit. Its published standings are left as they were; beside each one it would have rewritten, it gets an inactive FAILED draft carrying the cause, which the next attempt writes into and promotes on success (#1466). See the FAILED draft. When the leaderboard had no FAILED draft before, a configured operator is emailed once (US #1463; see the operator email). The reactor then throws, so the framework records the failure against it and retries with backoff. handleSignal and sweep both suspend the caller’s transaction: joined to the processor’s, a failed write would mark it rollback-only and lose the very record of the failure.

The sweep retries; it does not reconcile. Every ten minutes it recalculates the leaderboards that have a FAILED draft, for the organisations the reactor is scoped to. It does not find a standing that is merely stale: nothing records when a race result last changed, so a change that emitted no signal — written outside RaceResultServiceEx, or consumed while the reactor was disabled or not scoped to the organisation — is not caught. Recalculating everything instead would restate published standings. The manual recalculation endpoints cover that case.

Rollout. Enabled by default, because it replaces behaviour that was always on, but organisation-scoped like every reactor that writes tenant data. Until an environment names the organisations that run leaderboards in ems.signal-sweep.reactors.leaderboard.organisations, no standing is recalculated automatically there. A service on the dev profile has the whole framework switched off, so it recalculates nothing automatically either unless the framework is enabled for it.

Not yet handled. PERSON_MERGED and PARTICIPANT_CATEGORY_CHANGED have no emitter. A category change does move a rider into or out of a leaderboard’s linked categories, and so changes a school standing; so does changing a participant’s school. Neither raises a signal today.

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.