Skip to content

Commit adb0fc2

Browse files
authored
Merge pull request #156 from Ezeh20/feat/Add-Outbox-Event-Relay-and-Handler-Registry
Feat/add outbox event relay and handler registry
2 parents 41056eb + b37cfdf commit adb0fc2

54 files changed

Lines changed: 3065 additions & 240 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ REDIS_URL=redis://localhost:6379
3939
# Wallet-provisioning worker poll interval (ms)
4040
WORKER_POLL_INTERVAL_MS=5000
4141

42+
SCHEDULER_INTERVAL_MS=15000
43+
SCHEDULER_LEASE_MS=60000
44+
SCHEDULER_SHUTDOWN_TIMEOUT_MS=30000
45+
SCHEDULER_QUEUES=
46+
SCHEDULER_DISABLED_QUEUES=
47+
SCHEDULER_IN_PROCESS=false
48+
4249
# Logging Configuration
4350
# LOG_LEVEL=info (options: error, warn, info, http, verbose, debug, silly)
4451

@@ -55,17 +62,16 @@ STELLAR_FUNDING_MAX_RETRIES=5
5562
# Account Lifecycle Configuration
5663
DELETION_COOLING_OFF_DAYS=30
5764
EXPORT_TTL_DAYS=7
58-
# Interval for the background lifecycle sweep (export generation, deletion finalization). 0 = disabled (lazy sweep on requests only)
5965
LIFECYCLE_SWEEP_INTERVAL_MS=0
6066

6167
# Phone OTP Configuration
6268
# Only "mock" is implemented until a real carrier (Twilio, Termii, etc.) is integrated — see docs/decisions/0001-phone-otp-authentication.md
6369
SMS_PROVIDER=mock
6470
RATE_LIMIT_OTP_WINDOW_MS=900000
6571
RATE_LIMIT_OTP_MAX=5
66-
67-
# Data Lifecycle / Audit Configuration — see docs/DATA_LIFECYCLE.md
68-
# HMAC key for the source-IP hash on immutable audit events. Rotating it makes
69-
# older hashes uncorrelatable with newer ones. If unset in production, audit
70-
# events omit the IP hash rather than storing an unkeyed (reversible) digest.
71-
# AUDIT_IP_HASH_SECRET=change-me-in-production
72+
73+
# Data Lifecycle / Audit Configuration — see docs/DATA_LIFECYCLE.md
74+
# HMAC key for the source-IP hash on immutable audit events. Rotating it makes
75+
# older hashes uncorrelatable with newer ones. If unset in production, audit
76+
# events omit the IP hash rather than storing an unkeyed (reversible) digest.
77+
# AUDIT_IP_HASH_SECRET=change-me-in-production

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,9 @@ COPY --from=build /app/prisma.config.ts ./
8585
# Entrypoint scripts
8686
COPY docker/entrypoint-api.sh ./entrypoint-api.sh
8787
COPY docker/entrypoint-worker.sh ./entrypoint-worker.sh
88+
COPY docker/entrypoint-scheduler.sh ./entrypoint-scheduler.sh
8889

89-
RUN chmod +x entrypoint-api.sh entrypoint-worker.sh
90+
RUN chmod +x entrypoint-api.sh entrypoint-worker.sh entrypoint-scheduler.sh
9091

9192
# Non-root user
9293
RUN groupadd --gid 1001 appgroup && \

docker-compose.yml

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
# ============================================================================
22
# Learnault API — Local Development Stack
33
#
4-
# One command starts a healthy stack (API + wallet worker + PostgreSQL + Redis):
4+
# One command starts a healthy stack (API + PostgreSQL + Redis):
55
# docker compose up -d --build
66
#
77
# The API container applies migrations and seeds deterministic fixtures on
8-
# boot (see docker/entrypoint-dev-api.sh). The worker drains the idempotent
9-
# wallet-provisioning outbox (see src/workers/wallet-provisioning.worker.ts).
8+
# boot (see docker/entrypoint-dev-api.sh).
109
#
1110
# Useful commands:
1211
# docker compose ps → service status + health
@@ -95,10 +94,7 @@ services:
9594
start_period: 20s
9695
stop_grace_period: 30s
9796

98-
# --------------------------------------------------------------------------
99-
# Worker — drains the wallet-provisioning outbox
100-
# --------------------------------------------------------------------------
101-
worker:
97+
scheduler:
10298
build:
10399
context: .
104100
dockerfile: docker/Dockerfile.dev
@@ -110,12 +106,17 @@ services:
110106
NODE_ENV: development
111107
DATABASE_URL: postgresql://${POSTGRES_USER:-learnault}:${POSTGRES_PASSWORD:-learnault}@db:5432/${POSTGRES_DB:-learnault_dev}?schema=public
112108
RUN_MIGRATIONS: 'true'
113-
WORKER_POLL_INTERVAL_MS: ${WORKER_POLL_INTERVAL_MS:-5000}
109+
LOG_LEVEL: ${LOG_LEVEL:-info}
110+
SCHEDULER_INTERVAL_MS: ${SCHEDULER_INTERVAL_MS:-15000}
111+
SCHEDULER_LEASE_MS: ${SCHEDULER_LEASE_MS:-60000}
112+
SCHEDULER_SHUTDOWN_TIMEOUT_MS: ${SCHEDULER_SHUTDOWN_TIMEOUT_MS:-30000}
113+
SCHEDULER_QUEUES: ${SCHEDULER_QUEUES:-}
114+
SCHEDULER_DISABLED_QUEUES: ${SCHEDULER_DISABLED_QUEUES:-}
114115
volumes:
115116
- .:/app
116117
- /app/node_modules
117-
command: ['./docker/entrypoint-dev-worker.sh']
118-
stop_grace_period: 30s
118+
command: ['./docker/entrypoint-dev-scheduler.sh']
119+
stop_grace_period: 40s
119120

120121
volumes:
121122
pgdata:

docker/entrypoint-dev-scheduler.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/bin/sh
2+
set -e
3+
4+
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
5+
echo "[entrypoint] Applying database migrations …"
6+
npx prisma migrate deploy
7+
echo "[entrypoint] Migrations applied."
8+
fi
9+
10+
echo "[entrypoint] Starting scheduled job runner …"
11+
exec pnpm scheduler:dev

docker/entrypoint-dev-worker.sh

Lines changed: 0 additions & 18 deletions
This file was deleted.

docker/entrypoint-scheduler.sh

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/bin/sh
2+
set -e
3+
4+
if [ "${RUN_MIGRATIONS}" = "true" ]; then
5+
echo "[entrypoint] Running database migrations …"
6+
npx prisma migrate deploy
7+
echo "[entrypoint] Migrations applied."
8+
fi
9+
10+
echo "[entrypoint] Starting scheduled job runner …"
11+
exec node dist/workers/scheduler.worker.js

docs/DATA_LIFECYCLE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,12 +154,15 @@ rather than removing it.
154154
| `OutboxEvent` | MUTABLE | 30d (`createdAt`) | Retain | No |
155155
| `JobAttempt` | MUTABLE | 30d (`createdAt`) | Cascade | No |
156156
| `RolledBackRecord` | IMMUTABLE | 30d (`createdAt`) | Retain | No |
157+
| `QueueLease` | MUTABLE | Indefinite | Retain | No |
157158
| `WalletProvisioningJob` | MUTABLE | 90d (`updatedAt`) | Cascade | No |
158159

159160
`EmailDelivery` and `NotificationLog` hold rendered message bodies, which is
160161
personal data — hence the short window and hard deletion on erasure.
161162
`DeviceToken` is deleted rather than archived: an archived push token would still
162-
be a live address.
163+
be a live address. `QueueLease` holds one long-lived row per recurring queue
164+
drain — a queue name, the current lease token, and the holder id — so there is
165+
no user data to erase and nothing to age out.
163166

164167
---
165168

docs/DEVELOPMENT_STACK.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Local Development Stack (Docker Compose)
22

3-
A reproducible local stack for the Learnault API: **API**, **wallet worker**, **PostgreSQL**, and **Redis** — started with one command.
3+
A reproducible local stack for the Learnault API: **API**, **wallet worker**, **scheduler**, **PostgreSQL**, and **Redis** — started with one command.
44

55
## Prerequisites
66

@@ -23,6 +23,7 @@ docker compose ps
2323
# learnault-dev-db Up ... (healthy)
2424
# learnault-dev-redis Up ... (healthy)
2525
# learnault-dev-worker Up ... (healthy)
26+
# learnault-dev-scheduler Up ...
2627
```
2728

2829
The API is available at `http://localhost:5000` (Swagger UI at `http://localhost:5000/api-docs`).
@@ -37,6 +38,37 @@ The `api` service entrypoint (`docker/entrypoint-dev-api.sh`) waits for PostgreS
3738

3839
The `worker` service runs `src/workers/wallet-provisioning.worker.ts`, which polls the idempotent wallet-provisioning outbox and generates Stellar keys through the dev in-memory KMS adapter. In production, swap the KMS adapter for a real one (e.g. AWS KMS) behind the same `KmsSecretStore` interface.
3940

41+
The `scheduler` service runs `src/workers/scheduler.worker.ts`. See below.
42+
43+
## Scheduled job runner
44+
45+
Every recurring queue drain is owned by the `scheduler` service, not by the request that enqueued the work — so a delivery whose `nextAttemptAt` falls due is retried on time even when the API is receiving no traffic, and request latency never includes queue-drain work.
46+
47+
Registered queues: `email`, `notification`, `webhook`, `stellar-funding`, `data-export`, `account-lifecycle`.
48+
49+
Each tick takes a row lease on `queue_leases` via `JobLeaseService.acquireQueueLease()` before draining, so extra replicas are safe:
50+
51+
```bash
52+
docker compose up -d --scale scheduler=2
53+
```
54+
55+
A replica that loses the race logs a skipped tick and moves on; a replica that crashes mid-drain has its lease expire, and the next tick reclaims the queue.
56+
57+
| Variable | Default | Purpose |
58+
| --- | --- | --- |
59+
| `SCHEDULER_INTERVAL_MS` | `15000` | Base tick interval for every queue |
60+
| `SCHEDULER_<QUEUE>_INTERVAL_MS` || Per-queue override, e.g. `SCHEDULER_WEBHOOK_INTERVAL_MS` |
61+
| `SCHEDULER_LEASE_MS` | `60000` | Lease held per tick (floored at 2× the interval) |
62+
| `SCHEDULER_QUEUES` | all | Comma list restricting which queues this replica runs |
63+
| `SCHEDULER_DISABLED_QUEUES` || Comma list of queues to skip |
64+
| `SCHEDULER_SHUTDOWN_TIMEOUT_MS` | `30000` | How long `SIGTERM` waits for in-flight ticks |
65+
| `SCHEDULER_IN_PROCESS` | `false` | Opt-in: run the runner inside the API process for single-process deployments |
66+
| `LIFECYCLE_SWEEP_INTERVAL_MS` | `0` | When `> 0`, overrides the `account-lifecycle` queue interval |
67+
68+
Every tick emits a structured log line carrying per-queue `depth`, `due`, `lagMs` (age of the oldest due row), `durationMs`, and cumulative `attempts` / `failures` / `skipped`.
69+
70+
`pnpm scheduler:verify` runs both evidence scenarios against the stack: a due-but-failed delivery drained with no inbound HTTP traffic, then a batch drained by two replicas with no row processed twice.
71+
4072
## Health checks & readiness
4173

4274
| Endpoint | Meaning |
@@ -54,7 +86,7 @@ The API container only reports **healthy** after `/health/live` responds; `depen
5486
pnpm stack:up # docker compose up -d --build
5587
pnpm stack:down # stop the stack (keeps data volumes)
5688
pnpm stack:reset # stop + delete data volumes (project-scoped reset)
57-
pnpm stack:logs # follow API + worker logs
89+
pnpm stack:logs # follow API + worker + scheduler logs
5890
pnpm stack:validate # docker compose config --quiet
5991
pnpm stack:smoke # validate + start + probe health endpoints
6092
```
@@ -65,9 +97,10 @@ pnpm stack:smoke # validate + start + probe health endpoints
6597
docker compose logs -f # all services
6698
docker compose logs -f api # API only
6799
docker compose logs worker # worker only
100+
docker compose logs -f scheduler # scheduled job runner only
68101
```
69102

70-
Both services have `stop_grace_period: 30s`, matching the app's graceful-shutdown handler (`SHUTDOWN_TIMEOUT_MS`): `docker compose down` sends `SIGTERM`, the server drains HTTP connections and closes the Prisma pool before exiting.
103+
`api` and `worker` have `stop_grace_period: 30s` and `scheduler` has `40s`, matching each process's graceful-shutdown handler (`SHUTDOWN_TIMEOUT_MS` / `SCHEDULER_SHUTDOWN_TIMEOUT_MS`): `docker compose down` sends `SIGTERM`, the server drains HTTP connections and closes the Prisma pool before exiting, and the scheduler stops scheduling, waits for in-flight ticks, and releases their queue leases so no queue is left parked.
71104

72105
## Data persistence & reset
73106

docs/domains/REQUEST_AND_EVENT_FLOWS.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ This document maps the key request flows and domain event propagation patterns a
1414
6. [Referral Application Flow](#referral-application-flow)
1515
7. [Withdrawal Flow](#withdrawal-flow)
1616
8. [Notification Delivery Flow](#notification-delivery-flow)
17+
9. [Subscribing to Domain Events](#subscribing-to-domain-events)
1718

1819
---
1920

@@ -508,3 +509,121 @@ Target state: Event-driven communication via domain events
508509
| Credentials | Blockchain Infra | Service Call | On-chain storage |
509510
| Credentials | Notifications | Domain Event | Credential notification |
510511
| All Domains | Shared Kernel | Direct Import | Config, errors, middleware, utils |
512+
513+
---
514+
515+
## Subscribing to Domain Events
516+
517+
A new domain event needs a registered handler, not a new worker process. The
518+
outbox relay leases every pending event and dispatches it by `eventType` to the
519+
handlers registered for that type.
520+
521+
### 1. Declare the event schema
522+
523+
Add the payload schema in `src/lib/transactions/event-schema.ts`. A handler for
524+
an event type with no schema is rejected at startup.
525+
526+
```ts
527+
registry.register({
528+
version: 1,
529+
eventType: 'ModuleCompleted',
530+
validate: async (payload) => {
531+
await z.object({
532+
completionId: z.string().uuid(),
533+
userId: z.string().uuid(),
534+
}).parseAsync(payload)
535+
},
536+
})
537+
```
538+
539+
### 2. Emit the event in the same transaction as the domain write
540+
541+
```ts
542+
await prisma.$transaction(async (tx) => {
543+
const completion = await tx.completion.create({ data: ... })
544+
545+
await createOutboxService(prisma).createEvent(tx, {
546+
aggregateId: completion.id,
547+
aggregateType: 'Completion',
548+
eventType: 'ModuleCompleted',
549+
eventVersion: 1,
550+
payload: { completionId: completion.id, userId },
551+
source: 'api.module.complete',
552+
})
553+
554+
return completion
555+
})
556+
```
557+
558+
If the transaction rolls back the event disappears with it, so an event can
559+
never describe a write that did not happen.
560+
561+
### 3. Write the handler
562+
563+
```ts
564+
export class RewardOnModuleCompleted implements OutboxEventHandler {
565+
readonly name = 'rewards.on-module-completed'
566+
readonly eventType = 'ModuleCompleted'
567+
readonly eventVersion = 1
568+
readonly maxAttempts = 5
569+
570+
async handle(ctx: OutboxEventHandlerContext) {
571+
const payload = ctx.payload as ModuleCompletedPayload
572+
await rewardService.grant(payload.userId, payload.completionId)
573+
574+
return { idempotencyKey: `${ctx.eventId}:${this.name}` }
575+
}
576+
}
577+
```
578+
579+
`name` must be unique across the whole registry — it becomes `JobAttempt.jobType`.
580+
581+
**Handlers must be idempotent.** A handler can run more than once for the same
582+
event: after a crash mid-lease, or after an operator replays a dead-lettered
583+
event. Make the side effect an upsert, or guard it on a key derived from
584+
`ctx.eventId`.
585+
586+
Throwing from `handle()` schedules a retry with exponential backoff. Returning
587+
normally completes the attempt.
588+
589+
### 4. Register it
590+
591+
Add the handler in `src/jobs/handler-registrations.ts`, and add its event type
592+
to `EMITTED_EVENT_TYPES` if the application emits it:
593+
594+
```ts
595+
registry.register(new RewardOnModuleCompleted())
596+
```
597+
598+
`registerOutboxHandlers()` throws at startup on a duplicate handler name, on a
599+
handler whose event type has no schema, and on an emitted event type with no
600+
handler — so a missing subscription fails loudly instead of leaving rows PENDING
601+
forever.
602+
603+
### What the relay guarantees
604+
605+
- One `JobAttempt` per (event, handler). Several handlers may subscribe to the
606+
same event type and each is tracked separately.
607+
- An event becomes `PUBLISHED` only once **every** handler for its type has
608+
completed. One failing handler holds the event back without blocking others.
609+
- A handler that exhausts `maxAttempts` dead-letters its own job and the event,
610+
leaving every other event type unaffected.
611+
- An event with no registered handler is dead-lettered immediately and logged at
612+
error level, rather than sitting `PENDING` unnoticed.
613+
614+
### Operating dead letters
615+
616+
```bash
617+
pnpm outbox:replay list # dead-lettered events and last error
618+
pnpm outbox:replay replay <eventId> ... # reset to PENDING for another pass
619+
```
620+
621+
Replay resets the dead-lettered `JobAttempt` rows and returns the event to
622+
`PENDING`; the relay picks it up on its next tick. Completed handlers are not
623+
re-run, and idempotent handlers make a repeated run harmless.
624+
625+
### Where it runs
626+
627+
The relay is a queue on the scheduled job runner
628+
(`src/workers/scheduler.worker.ts`), registered as `outbox-relay`. There is no
629+
per-domain worker process: adding a domain event means adding a handler.

learnault-api@0.1.0

Whitespace-only changes.

0 commit comments

Comments
 (0)