Outbox Pattern
LinkOStar handles every outbound push to an external system as a transactional outbox. The external call only fires after the local DB transaction commits, so we never end up in a "the supplier was told about X but our database has no X" state. The two outboxes shipped this sprint (V8 activation + V12 supplier registration) share the same shape; supplier receivers handle both identically.
Why outbox
If activation INSERT POSTed to the supplier directly, three failure modes appear:
- Network blip → POST fails → our transaction rolls back → supplier never learned, no retry trail.
- Network fine + our transaction rolls back (for a different reason) → supplier thinks it's done, our DB has no row. Unrecoverable.
- Synchronous calls eat our latency budget; a supplier 5xx directly hits our SLO.
The outbox fixes all three.
Common shape
[atomic transaction]
├── INSERT business row (HubInstance, DeviceActivationEvent, ...)
└── INSERT outbox row (sync_status / status = PENDING)
│
│ afterCommit hook (Spring TransactionSynchronization)
▼
[@Async Dispatcher (its own transaction)]
├── reload row
├── POST external endpoint (HMAC signature / Bearer / ...)
├── classify response:
│ 2xx → status = SENT/SYNCED, sent_at/synced_at recorded
│ 4xx → status = DEAD/REJECTED_BALANCE (retrying makes no sense)
│ 5xx / IO / timeout → status = FAILED + next_attempt_at
└── save row (@Propagation.REQUIRES_NEW)
[@Scheduled Retry Job (its own transaction)]
├── status = FAILED AND next_attempt_at <= now AND attempt_count < N
├── re-invoke Dispatcher.dispatch(id)
└── after max attempts → status = DEAD1. Activation outbound (V8)
| Concern | Implementation |
|---|---|
| Outbox table | device_activation_event + sync_* columns |
| External endpoint | sdx-web /api/m2m/billing/activation |
| Authentication | sdx-m2m OAuth client_credentials |
| Idempotency key | "dae-" + DeviceActivationEvent.id |
| Response classification | 201/200 SYNCED · 409 would_go_negative → REJECTED_BALANCE · 5xx/IO → FAILED |
| Ops monitoring | GET /platform/activation-events?syncStatus=... |
| Cut-over | Rows pre-V8 are marked BACKFILL_SKIPPED — never reach sdx-web |
2. Supplier registration push (V12)
| Concern | Implementation |
|---|---|
| Outbox table | supplier_registration_event |
| External endpoint | supplier_oauth_config hub_registration_url / device_registration_url |
| Authentication | HMAC-SHA256 (X-LinkOStar-Signature: sha256=<hex>) |
| Idempotency key | "HUB_REGISTERED:" + hubUuid / "DEVICE_REGISTERED:" + deviceUuid |
| Response classification | 2xx SENT · 4xx DEAD · 5xx/IO FAILED → backoff |
| Ops monitoring | (dedicated admin endpoint pending) |
3. Supplier receiver guide (V12)
Two endpoints. Sample bodies:
# POST <hub_registration_url>
{
"hub_uuid": "...",
"supplier_uuid": "...",
"tenant_uuid": "...",
"bundle_uuid": "...",
"hub_identifier":"...",
"hub_type": "OPEN" | "CLOSED",
"metadata": "<JSON string the supplier attached on claim code creation>"
}
# POST <device_registration_url>
{
"device_instance_id": 123,
"device_uuid": "...",
"device_version_uuid": "...",
"tenant_uuid": "...",
"first_seen_hub_uuid": "..." | null,
"hub_uuid": "..." | null,
"supplier_uuid": "...",
"mac_address": "...",
"source": "bundle_auto_provision" | "tenant_create"
}HMAC verification (Node.js example)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/api/m2m/linkostar/hub-registered',
express.raw({ type: 'application/json' }), // raw body for HMAC
(req, res) => {
const signature = req.header('X-LinkOStar-Signature');
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.LINKOSTAR_SHARED_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).end(); // never process if signature mismatches
}
const idemKey = req.header('X-LinkOStar-Idempotency-Key');
if (alreadyProcessed(idemKey)) return res.status(200).end();
const payload = JSON.parse(req.body.toString('utf-8'));
// ... persist / update internal models ...
markProcessed(idemKey);
res.status(200).end();
});4. Backoff policy
The dispatcher bumps attempt_count and sets next_attempt_at = now + 2^attempt × base (base = 1 min by default). After at most 8 attempts the row becomes DEAD. Tune via linkostar.supplier-registration.retry.* in application.yml.
5. Common foot-guns
- Dispatcher transaction— REQUIRES_NEW is the trick. If it joined the caller's transaction, an external failure could roll back the business row too.
- Don't dispatch outside afterCommit in production— the test path that calls dispatch directly only works because there's no enclosing transaction.
- Signature mismatch → 401 only. Never reveal anything in the response body, never log the secret.
- Idempotency — receivers must dedupe on
X-LinkOStar-Idempotency-Key. Retries are normal; processing twice is a bug. - 4xx vs 5xx — business-validation rejections (e.g. 422) should be 4xx so we mark DEAD and stop retrying. Transient failures should be 5xx so we keep trying.