LinkO'Star

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 = DEAD

1. Activation outbound (V8)

ConcernImplementation
Outbox tabledevice_activation_event + sync_* columns
External endpointsdx-web /api/m2m/billing/activation
Authenticationsdx-m2m OAuth client_credentials
Idempotency key"dae-" + DeviceActivationEvent.id
Response classification201/200 SYNCED · 409 would_go_negative → REJECTED_BALANCE · 5xx/IO → FAILED
Ops monitoringGET /platform/activation-events?syncStatus=...
Cut-overRows pre-V8 are marked BACKFILL_SKIPPED — never reach sdx-web

2. Supplier registration push (V12)

ConcernImplementation
Outbox tablesupplier_registration_event
External endpointsupplier_oauth_config hub_registration_url / device_registration_url
AuthenticationHMAC-SHA256 (X-LinkOStar-Signature: sha256=<hex>)
Idempotency key"HUB_REGISTERED:" + hubUuid / "DEVICE_REGISTERED:" + deviceUuid
Response classification2xx 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.