Signal & Sweep Framework Architecture
1. Overview
Signal & Sweep is the asynchronous trigger framework in admin-service. It replaces the pattern of adding a bespoke @Scheduled service for every new background concern with one mechanism that all concerns share.
The framework answers two questions for any piece of deferred work:
-
Something changed — react to it. A domain service emits a signal; the framework routes it to whichever reactors care.
-
Nothing told us, but check anyway. Each reactor declares a sweep — a periodic reconciliation scan that finds work the signals missed.
The term trigger is used throughout instead of "event", because "event" already means a sporting event in the EMS domain. A DomainSignal is not a Spring ApplicationEvent.
This document is reactor-agnostic: it describes the framework only. You should be able to implement a new reactor from this page alone. For the catalogue of concrete reactors and the signal types they consume, see Reactor Catalogue. For a step-by-step cookbook, see Adding a Reactor.
2. Core Concepts
| Concept | Definition |
|---|---|
Signal |
A persistent record that something happened in the domain. Emitted fire-and-forget by application code, stored in |
Reactor |
A Spring bean declaring (a) which signal types it handles and (b) a sweep method plus schedule. One processing concern implements one Reactor. A reactor may be signal-only, sweep-only, or both. |
Sweep |
A periodic run of a reactor’s reconciliation logic. Sweeps find work by inspecting domain state — timestamps, null columns, status flags — rather than by reading signals. Sweeps must be idempotent. |
Debounce (squash-on-emit) |
Multiple signals for the same concern and entity inside a time window collapse into one. Implemented at emit time by pushing an existing pending signal’s |
Aggregate key |
The string that defines "the same concern and entity" for squashing — for example |
2.1. Why both signals and sweeps
Signals alone are not enough:
-
Not every change goes through application code — SQL scripts, batch imports and external callbacks all bypass the emit point.
-
If the application dies after the domain change commits but before the signal does, the work is lost.
-
Some concerns have no discrete trigger at all; they are purely time-based.
Sweeps alone are not enough:
-
For time-sensitive reactions such as a confirmation email, waiting for the next sweep is too slow.
-
Sweep frequency trades responsiveness against database load. Signals remove that trade-off.
Together they are belt and braces: signals give speed, sweeps give correctness. A reactor that must never miss work should implement both, and its sweep should be able to find everything its signal handler would have done.
3. Signal Lifecycle
A signal is matured when process_after ⇐ NOW(). The processor never sees a signal before its debounce window has elapsed.
A signal with no registered reactor for its type is picked up and marked COMPLETED immediately, having done nothing. This is deliberate and useful: it lets a signal type be emitted and observed in production before any reactor acts on it.
4. Data Model
Three tables, all in the database module. None of them are Org-Scoped — they are system service tables and do not participate in the security model. See Security classification.
All three share the signal_ prefix so they group together in database tools. Note that the table name and the JPA entity name deliberately diverge for the first one: the table is signal_domain (for grouping), the entity is DomainSignal (because that is what it is). Do not "fix" either to match the other.
4.1. signal_domain
CREATE TABLE signal_domain (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
signal_type VARCHAR(100) NOT NULL, -- e.g. 'RESULT_SET_CHANGED'
entity_type VARCHAR(100), -- e.g. 'ResultSet', 'Order'
entity_id BIGINT, -- PK of the changed entity
aggregate_key VARCHAR(200), -- squash key, e.g. 'series:8'
payload JSON, -- optional context
organisation_id BIGINT, -- context/filtering only
status VARCHAR(20) DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT NOW(),
process_after TIMESTAMP NOT NULL, -- debounce: not before this time
updated_at TIMESTAMP, -- last squash time
processed_at TIMESTAMP,
error_message VARCHAR(2000),
retry_count INT DEFAULT 0,
max_retries INT DEFAULT 3,
next_retry_at TIMESTAMP, -- exponential backoff
INDEX idx_ready (status, process_after),
INDEX idx_squash (signal_type, aggregate_key, status)
);
| Column | Notes |
|---|---|
|
The debounce deadline. The processor selects only |
|
Defines squash identity together with |
|
Optional context to save a re-query. Replaced by the latest change on squash, so never treat it as a change history. Reactors should read current entity state, not the payload, whenever correctness depends on it. |
|
Nullable. For filtering in admin queries only — not a security boundary. |
|
When the signal was last squashed. Useful diagnostically: a large gap from |
|
Exponential backoff. The processor skips signals whose |
4.2. signal_reactor_status
One signal type may be consumed by several reactors. A single status column on signal_domain cannot express "reactor A succeeded, reactor B failed", so delivery is tracked per reactor.
CREATE TABLE signal_reactor_status (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
signal_id BIGINT NOT NULL,
reactor_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL, -- PENDING, COMPLETED, FAILED, DEAD
processed_at TIMESTAMP,
error_message VARCHAR(2000),
retry_count INT DEFAULT 0,
UNIQUE KEY uk_signal_reactor (signal_id, reactor_name),
FOREIGN KEY (signal_id) REFERENCES signal_domain(id) ON DELETE CASCADE
);
The natural key is (signal_id, reactor_name), but it is enforced as a unique index over a surrogate id rather than used as a composite primary key. This follows the codebase convention — no domain entity uses @IdClass or @EmbeddedId — and keeps Spring Data repositories straightforward. The uniqueness guarantee is identical.
This table exists so that a retry re-invokes only the reactors that failed. Without it, retrying a signal because reactor B timed out would run reactor A a second time — wasteful when A is expensive, and dangerous when A has a non-idempotent side effect such as sending an email.
The signal’s overall status is derived from its reactor rows:
| Reactor rows | Signal status |
|---|---|
All |
|
Any |
stays |
All either |
|
4.3. signal_sweep_lease
CREATE TABLE signal_sweep_lease (
lease_name VARCHAR(50) PRIMARY KEY, -- 'SIGNAL_MASTER' or 'SWEEP_MASTER'
holder_id VARCHAR(100) NOT NULL, -- pod identifier
acquired_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
INDEX idx_expires (expires_at)
);
4.4. Security classification
All three tables are system service tables, not user-facing entities:
-
No
OrganizationalSecuredinterface, noSecurityDimensionServiceintegration. -
organisation_idonsignal_domainis nullable, and is used for admin filtering and for reactor scoping — see Organisation Scoping. It is not part of the security model: no reactor derives a caller’s rights from it. -
Access to signal data is via the admin API, which is
ROLE_ADMINonly.
See Entity Classification for how this differs from Org-Scoped entities.
5. Debounce: Squash on Emit
Debounce happens when a signal is emitted, not when it is processed. This keeps the processor simple — it only ever sees work that is ready.
SignalPublisher.emit() does:
-
Look up the debounce window configured for this
signal_type. -
Issue one
INSERT … ON DUPLICATE KEY UPDATEagainstsignal_domain. If aPENDINGsignal already exists for the samesignal_type+aggregate_key, itsprocess_aftermoves tonow + window, itspayloadis replaced andupdated_atis set. Otherwise a new signal is inserted.
One statement, and no lock contention beyond the single row being written.
5.1. Why one statement rather than update-then-insert
The obvious implementation is two statements: try to squash, and insert if there was nothing to squash into. Two threads can then both find nothing and both insert.
uk_signal_domain_pending (see Data Model) turns that race into a constraint violation rather than a silent duplicate, which is an improvement — but not one that can be caught and retried in the publisher. A constraint violation marks the transaction rollback-only, and that transaction belongs to the caller. Recovering from it would mean destroying the caller’s registration or payment in order to save a signal, which is exactly backwards.
A single atomic statement has no race to lose, no exception to catch, and never puts the caller’s transaction at risk.
The statement uses MySQL’s row-alias form (AS incoming … ON DUPLICATE KEY UPDATE col = incoming.col), not the older VALUES() function, which is deprecated from MySQL 8.0.20. Every assignment reads from the alias and never from a column being assigned in the same clause — MySQL evaluates those left to right and later expressions see already-updated values, so a clause that reads its own columns is order-dependent and easy to get subtly wrong.
5.2. Non-squashable signal types
A signal type configured with a zero window is one where every occurrence matters individually — one payment, one email. For those, SignalPublisher stores no aggregate key at all.
That is not an omission. The aggregate key is the squash key: storing one would let two such signals collapse into a single row and silently lose a customer communication. With aggregate_key null the generated pending_squash_key is also null, nothing conflicts, and every emit inserts. Find these signals by entity_type + entity_id instead.
A signal type with no configured window falls back to a non-zero default rather than to zero, so a forgotten configuration entry degrades to "batched more than intended" rather than silently disabling squashing.
Because the payload is overwritten on each squash, the reactor sees only the final state. That is normally what you want — the reactor reads the entity as it now stands. If a reactor needs to know about every intermediate change, squashing is the wrong model for it and its signal type should use a zero window.
6. Framework Components
6.1. SignalPublisher
The only part of the framework domain code touches. Fire-and-forget.
signalPublisher.emit(
"PAYMENT_COMPLETED", // signal type
"Order", // entity type
order.getId(), // entity id
"order:" + order.getId(), // aggregate key
order.getOrganisationId(), // organisation context (nullable)
Map.of("amount", amount) // optional payload
);
Transaction participation is deliberate. emit() joins the caller’s transaction. If the caller rolls back, the signal is never persisted — correct, because there is no domain change to react to. The cost is that the signal is not visible until the caller commits; that latency is accepted in exchange for never having a signal that refers to a change which did not happen.
The corollary matters when writing emit calls: emit after the domain change, inside the same transaction. Do not emit from a @TransactionalEventListener(AFTER_COMMIT) or a new transaction — that reintroduces the orphan-signal problem the design avoids.
6.2. SignalProcessor
A single scheduled poller.
@Scheduled(fixedRateString = "${ems.signal-sweep.signal-processor.poll-interval}")
@Transactional
public void processPendingSignals() {
if (!leaseManager.isSignalMaster()) return;
if (!runtimeControl.isProcessorEnabled()) return;
List<DomainSignal> ready = signalRepository.findReadySignals(Instant.now(), batchSize);
// WHERE status = 'PENDING' AND process_after <= :now ORDER BY process_after ASC
for (DomainSignal signal : ready) {
// mark PROCESSING, then for each reactor registered for this signal type:
// skip if its signal_reactor_status is already COMPLETED
// invoke reactor.handleSignal(signal)
// record COMPLETED, or FAILED with backoff
// finally derive the signal's overall status
}
}
Key behaviours:
-
Only matured signals are selected. Debounce is already done.
-
A reactor is filtered out for signals outside its organisation set before it is invoked.
-
Reactors are invoked independently; one throwing does not prevent the others from running.
-
A reactor already
COMPLETEDfor this signal is skipped on retry. -
Failures are recorded per reactor with
retry_countandnext_retry_at; exhausted retries becomeDEAD.
6.3. SweepScheduler
Registers each reactor’s sweep against its declared cron at startup.
@PostConstruct
public void scheduleReactorSweeps() {
for (Reactor reactor : reactors) {
if (reactor.getSweepCron() != null) {
taskScheduler.schedule(() -> executeSweep(reactor),
new CronTrigger(reactor.getSweepCron()));
}
}
}
executeSweep guards on isSweepMaster(), the reactor’s effective enabled state, and — for an organisation-scoped reactor — a non-empty effective organisation set, then calls reactor.sweep() inside a try/catch. A failing sweep is logged and does not unregister the schedule.
A scoped sweep must apply the organisation set to its own selection query. The framework cannot verify that, so it enforces what it can: it refuses to start the sweep at all when the set is empty.
Sweeps must be idempotent. They will run again on the next tick, on another pod after failover, and on demand from the admin API.
6.4. The Reactor interface
public interface Reactor {
/** Unique name — used in config keys, logging, metrics and the admin API. */
String getName();
/** Signal types this reactor handles. Empty set = sweep-only. */
Set<String> getHandledSignalTypes();
/** Handle one signal. Throwing marks this reactor FAILED and schedules a retry. */
void handleSignal(DomainSignal signal);
/** Periodic reconciliation over domain state. Must be idempotent. */
void sweep();
/** Spring cron expression for the sweep. Null = signal-only. */
String getSweepCron();
/** Whether this reactor acts on tenant data. Defaults to true. */
default boolean isOrganisationScoped() { return true; }
}
Reactors are discovered as Spring beans. Implementing the interface and annotating the class is all that is needed to register one — there is no central registry to edit.
Note what the interface does not contain: there is no coalesce or debounce method. Debounce is a framework concern configured per signal type, not a reactor decision, because one signal type may feed several reactors that all benefit from the same window.
6.6. RuntimeControlService
See Runtime Control.
7. Multi-Node Leader Election
admin-service runs on several Kubernetes pods. Without coordination every pod would poll signal_domain and run every sweep, duplicating work and contending on the same rows.
Two independent leases are elected, held in signal_sweep_lease:
| Role | Responsibility |
|---|---|
|
The one pod that runs |
|
The one pod that executes reactor sweeps. |
Separating the roles allows them to land on different pods, though in practice one pod usually holds both. On a single-pod deployment that pod holds both automatically.
7.1. Lease protocol
-
On startup each pod generates a unique
holder_id—${HOSTNAME}plus its startup timestamp, so a restarted pod on the same hostname does not inherit its own stale lease. -
Every heartbeat interval, each pod attempts to acquire or renew each lease with a single
INSERT … ON DUPLICATE KEY UPDATEwhose conditional logic renews only its own lease and claims another’s only once expired. -
The pod reads the row back. If
holder_idmatches, it is the leader. -
SignalProcessorandSweepSchedulereach begin with a one-line guard.
The TTL must exceed the heartbeat interval or leases will flap. With the defaults (heartbeat 15s, TTL 30s), a crashed leader’s work resumes within roughly TTL + heartbeat = 45 seconds. That gap is harmless: signals are durable and simply wait.
7.2. Why not SELECT … FOR UPDATE SKIP LOCKED
Distributing signal rows across pods by row-locking was considered and rejected:
-
Squashing assumes a single writer’s view of what is pending; splitting processing across pods undermines coalescing.
-
Sweeps scan domain tables, not the signal table — they cannot be partitioned this way.
-
One master means one place to look for logs and metrics.
At the expected volume — hundreds to low thousands of signals a day — throughput is not the constraint. If it ever becomes one, the framework can shard by signal type without changing the reactor contract.
8. Runtime Control
Reactors, the processor and the scheduler can each be enabled or disabled at runtime, in the same spirit as changing a log level in production. There are three levels:
| Level | Behaviour |
|---|---|
Config |
From |
Runtime override |
Set via the admin API. In-memory ( |
Effective |
|
The override is per pod, not cluster-wide. Disabling a reactor during an incident means calling the endpoint on the pod holding the relevant lease, or on all pods.
This matters most in two situations:
-
Development. A developer’s machine should not run sweeps against a shared database or send email. The dev profile turns everything off and the developer enables just the reactor under test.
-
Production incident. A reactor hammering an external API can be stopped immediately, without a redeploy.
9. Organisation Scoping
A reactor that acts on tenant data may only act for organisations it has been explicitly given. The set is framework-level, not per reactor, so a new sweep-based reactor inherits the constraint rather than rediscovering it.
This is a correctness precondition, not a rollout convenience. sales_order is a single table shared by both production lines while only one of them runs this framework, so an unscoped reactor reaches orders belonging to tenants this instance does not serve — minting external orders against the other line’s sites, or emailing its customers. The one-tenant-per-instance operating model depends on the filter.
Empty means none, not all. The configured default is an empty set, so a reactor deployed to an environment where nobody named its tenants processes no signals and runs no sweep. This mirrors enabled defaulting to false: silence must never enable behaviour.
Three rules follow from that:
-
Signal routing skips a signal whose organisation is outside the set. The reactor is filtered out before it is invoked, so the signal never reaches it. A signal no reactor is scoped for completes rather than lingering — nothing is waiting on it.
-
A signal carrying no organisation at all is skipped by an organisation-scoped reactor. An unattributable signal cannot be checked against the set, and guessing is how one line’s reactor ends up acting on the other’s data.
-
SweepSchedulerrefuses to start a scoped sweep whose set is empty. The framework cannot inspect a reactor’s own query, so it cannot prove the sweep honours the set; refusing to run it at all covers the case that matters.
A reactor declares whether it is subject to the filter through Reactor.isOrganisationScoped(), which defaults to true — the safe answer is the one a new reactor gets by writing nothing. SignalRetentionReactor overrides it to false: it maintains the framework’s own tables, has no tenant to be checked against, and filtering it would disable it permanently.
The set has the same three tiers as enabled and dry-run (see Runtime Control), and for the same reason: narrowing or widening it during a rollout must not require a redeploy. Because the configured default is empty, an override lost to a restart falls back to no organisations rather than all of them — losing the gate fails closed.
The setter is refused with 409 unless the pod holds SIGNAL_MASTER. Reactor flags are read only inside SignalProcessor.poll() and SweepScheduler, both of which return early on a non-master pod, so an override accepted elsewhere would change nothing while reporting success — and for a tenant gate that silent failure is in the widening direction.
Per-environment values belong in the deployment manifest, not in a profile file: application-prod.yml reaches stage and both production lines alike, so it cannot express a per-environment tenant set.
10. Configuration
All framework and reactor settings live under ems.signal-sweep:
ems:
signal-sweep:
enabled: true # master kill switch
lease:
ttl: 30s # must be greater than heartbeat
heartbeat: 15s
signal-processor:
poll-interval: 5s
batch-size: 50
retention:
days: 7 # purge COMPLETED/DEAD after this
debounce: # per signal type
RESULT_SET_CHANGED: 60s
REGISTRATION_COMPLETED: 0s # immediate — one signal, one action
PAYMENT_COMPLETED: 0s
PARTICIPANT_CATEGORY_CHANGED: 15m
reactors:
<reactor-name>:
enabled: true
sweep-cron: "0 */10 * * * *"
organisations: [] # tenants this reactor may act for; empty means NONE
Debounce is keyed by signal type, not reactor, because a type consumed by several reactors should mature once for all of them.
Each reactor owns the ems.signal-sweep.reactors.<name> subtree and may add its own keys there — a dry-run flag, thresholds, age limits. Keep reactor-specific settings inside that subtree rather than inventing a parallel namespace.
11. Retention
COMPLETED and DEAD signals are purged after the retention window (default 7 days) by a framework-internal sweep-only reactor. signal_reactor_status rows cascade with their parent.
Short retention is intentional: signal_domain is a work queue, not an audit log. The durable record of what happened lives in the domain entities and, for communications, in communication_log. Signals in PENDING, PROCESSING or FAILED are never purged regardless of age — an old row in one of those states is a fault worth seeing, not clutter.
12. Monitoring API
SignalAdminResource at /api/admin/signals, ROLE_ADMIN only.
Every endpoint returns 403 when the caller lacks ROLE_ADMIN, so it is omitted from the table below. The codes shown are the ones the published OpenAPI spec carries — springdoc reads annotations only, so an endpoint’s javadoc reaches maintainers but never a generated client.
Note the toggles return 204, not 200: they carry no body.
| Endpoint | Purpose | Responses |
|---|---|---|
|
Paginated list, filterable by status, signal type, aggregate key and date range. Filters resolve strictly — an unknown value is an error, not silently unfiltered rows |
|
|
One signal including its per-reactor delivery statuses — this is how you find which reactor failed |
|
|
Counts by status and type, oldest pending |
|
|
Signals in |
|
|
Reset a |
|
|
Mark a |
|
|
All registered reactors: handled types, sweep cron, last sweep, and the config/runtime/effective state of |
|
|
Runtime toggle for one reactor |
|
|
Runtime dry-run toggle for one reactor — |
|
|
Runtime override of the tenants one reactor may act for — |
|
|
Trigger a sweep immediately, bypassing the schedule but not the Sweep Master lease — |
|
|
Framework-level toggles |
|
|
Whole-framework overview |
|
|
Which pod currently holds each lease — start here to find the pod a |
|
A 409 on any of these is a routing answer, not a failure: the operation is valid but this pod is not the one that can perform it. GET /signals/leaders names the pod that can.
Diagnostic starting points:
-
Nothing is being processed — check
GET /signals/leaders(is anyone holdingSIGNAL_MASTER?) thenGET /signals/status(is the processor enabled?). -
One reactor is enabled but does nothing — check its organisation set in
GET /signals/reactors. An organisation-scoped reactor with an empty effective set is inert by design; see Organisation Scoping. -
One concern stopped working —
GET /signals?status=FAILEDand readerror_message, orGET /signals/{id}for the per-reactor breakdown. -
Work is slow to happen — check the debounce window for that signal type; a long window plus continuous activity means
process_afterkeeps sliding.
13. Observability
Beyond the API:
-
Logging — structured entries for emission, squash, processing, reactor failure and sweep execution, each carrying the reactor name and signal id.
-
Metrics (Micrometer) — counters for signals emitted, processed and failed per type; sweep duration histograms per reactor.
14. Related Documentation
-
Reactor Catalogue — signal types, aggregate key conventions, and the concrete reactors
-
Adding a Reactor — implementation cookbook
-
Email Service — the first downstream target
-
Communication Integration — where Fluxnova fits relative to this framework
-
Data Synchronisation — sweep-shaped by nature; a future reactor
-
Entity Classification — why these tables are not Org-Scoped