@nestarc/inbox 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +309 -2
  4. package/dist/contracts/errors.d.ts +34 -0
  5. package/dist/contracts/errors.js +73 -0
  6. package/dist/contracts/errors.js.map +1 -0
  7. package/dist/contracts/types.d.ts +174 -0
  8. package/dist/contracts/types.js +3 -0
  9. package/dist/contracts/types.js.map +1 -0
  10. package/dist/inbox-admin.service.d.ts +44 -0
  11. package/dist/inbox-admin.service.js +102 -0
  12. package/dist/inbox-admin.service.js.map +1 -0
  13. package/dist/inbox.module.d.ts +13 -0
  14. package/dist/inbox.module.js +59 -0
  15. package/dist/inbox.module.js.map +1 -0
  16. package/dist/inbox.service.d.ts +22 -0
  17. package/dist/inbox.service.js +74 -0
  18. package/dist/inbox.service.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.js +28 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/integrations/tenancy.d.ts +25 -0
  23. package/dist/integrations/tenancy.js +56 -0
  24. package/dist/integrations/tenancy.js.map +1 -0
  25. package/dist/internal/handler-context.d.ts +3 -0
  26. package/dist/internal/handler-context.js +39 -0
  27. package/dist/internal/handler-context.js.map +1 -0
  28. package/dist/internal/options.d.ts +5 -0
  29. package/dist/internal/options.js +86 -0
  30. package/dist/internal/options.js.map +1 -0
  31. package/dist/internal/reliability-core/abort.d.ts +2 -0
  32. package/dist/internal/reliability-core/abort.js +21 -0
  33. package/dist/internal/reliability-core/abort.js.map +1 -0
  34. package/dist/internal/reliability-core/index.d.ts +3 -0
  35. package/dist/internal/reliability-core/index.js +14 -0
  36. package/dist/internal/reliability-core/index.js.map +1 -0
  37. package/dist/internal/reliability-core/json.d.ts +13 -0
  38. package/dist/internal/reliability-core/json.js +125 -0
  39. package/dist/internal/reliability-core/json.js.map +1 -0
  40. package/dist/internal/reliability-core/retry.d.ts +8 -0
  41. package/dist/internal/reliability-core/retry.js +26 -0
  42. package/dist/internal/reliability-core/retry.js.map +1 -0
  43. package/dist/internal/validation.d.ts +6 -0
  44. package/dist/internal/validation.js +123 -0
  45. package/dist/internal/validation.js.map +1 -0
  46. package/dist/storage/prisma/prisma-inbox.storage.d.ts +30 -0
  47. package/dist/storage/prisma/prisma-inbox.storage.js +369 -0
  48. package/dist/storage/prisma/prisma-inbox.storage.js.map +1 -0
  49. package/dist/testing/index.d.ts +40 -0
  50. package/dist/testing/index.js +120 -0
  51. package/dist/testing/index.js.map +1 -0
  52. package/dist/worker/inbox-worker.d.ts +31 -0
  53. package/dist/worker/inbox-worker.js +343 -0
  54. package/dist/worker/inbox-worker.js.map +1 -0
  55. package/docs/adr/0001-durable-inbox.md +40 -0
  56. package/docs/benchmarks/2026-10-27-local.md +31 -0
  57. package/docs/benchmarks/2026-10-28-preview1-local.md +87 -0
  58. package/docs/benchmarks/2026-10-28-preview1-run1.json +120 -0
  59. package/docs/benchmarks/2026-10-28-preview1-run2.json +120 -0
  60. package/docs/benchmarks/2026-10-28-preview1-run3.json +120 -0
  61. package/docs/benchmarks/2026-10-28-sustained-local.md +104 -0
  62. package/docs/benchmarks/2026-10-28-sustained-run1.json +2553 -0
  63. package/docs/benchmarks/2026-10-28-sustained-run2.json +2553 -0
  64. package/docs/broker-recovery-results.json +52 -0
  65. package/docs/broker-recovery.md +30 -0
  66. package/docs/db-operations.md +41 -0
  67. package/docs/implementation-roadmap.md +129 -0
  68. package/docs/operations/2026-10-28-database.json +34 -0
  69. package/docs/package-plan.md +250 -0
  70. package/docs/releasing.md +91 -0
  71. package/docs/testing.md +86 -0
  72. package/docs/usage.md +266 -0
  73. package/docs/validation.md +174 -0
  74. package/examples/README.md +124 -0
  75. package/examples/compose.yaml +26 -0
  76. package/examples/package.json +42 -0
  77. package/examples/prisma/schema.prisma +29 -0
  78. package/examples/prisma.config.ts +10 -0
  79. package/examples/scripts/prepare-db.cjs +45 -0
  80. package/examples/scripts/smoke.cjs +37 -0
  81. package/examples/scripts/with-brokers.cjs +249 -0
  82. package/examples/scripts/with-rabbitmq.cjs +131 -0
  83. package/examples/sql/application.sql +14 -0
  84. package/examples/sql/tenancy.sql +26 -0
  85. package/examples/src/broker-common.ts +79 -0
  86. package/examples/src/bullmq-ingress/main.ts +165 -0
  87. package/examples/src/common.ts +112 -0
  88. package/examples/src/idempotency-ingress/main.ts +211 -0
  89. package/examples/src/kafka-ingress/main.ts +209 -0
  90. package/examples/src/outbox-to-inbox/main.ts +198 -0
  91. package/examples/src/rabbitmq-ingress/main.ts +203 -0
  92. package/examples/src/sqs-ingress/main.ts +273 -0
  93. package/examples/src/tenancy/main.ts +210 -0
  94. package/examples/src/webhook-ingress/main.ts +172 -0
  95. package/examples/tsconfig.json +16 -0
  96. package/package.json +99 -4
  97. package/src/sql/create-inbox-table.sql +100 -0
  98. package/src/sql/migrate-v1-to-v2.sql +53 -0
@@ -0,0 +1,52 @@
1
+ {
2
+ "checkedAt": "2026-10-28T13:15:03.229Z",
3
+ "packageVersion": "0.2.0",
4
+ "brokerImage": "rabbitmq:4-alpine",
5
+ "brokerVersion": "4.4.0",
6
+ "postgresVersion": "16.15",
7
+ "nodeVersion": "v24.11.1",
8
+ "brokerSigkillRestarts": 3,
9
+ "elapsedMs": 21752,
10
+ "results": [
11
+ {
12
+ "scenario": "SIGKILL after receipt commit before ACK",
13
+ "redelivered": true,
14
+ "receipt": "duplicate",
15
+ "committedEffects": 1
16
+ },
17
+ {
18
+ "scenario": "admission failure, confirmed retry transfer, broker SIGKILL, TTL redelivery",
19
+ "transportRetries": 1,
20
+ "committedEffects": 1
21
+ },
22
+ {
23
+ "scenario": "persistent admission failure exhausts transport retry budget into DLQ",
24
+ "acceptanceFailures": 4,
25
+ "transportRetries": 3,
26
+ "committedReceipts": 0,
27
+ "committedEffects": 0
28
+ },
29
+ {
30
+ "scenario": "invalid JSON and identity conflict reach transport DLQ",
31
+ "deadLetters": 2,
32
+ "newCommittedEffects": 0
33
+ },
34
+ {
35
+ "scenario": "Inbox retry ownership survives broker and service restart",
36
+ "transientAttempts": 2,
37
+ "poisonAttempts": 3,
38
+ "committedEffects": {
39
+ "transient": 1,
40
+ "poison": 0
41
+ },
42
+ "poisonStatus": "DEAD"
43
+ }
44
+ ],
45
+ "finalCounts": {
46
+ "PENDING": 0,
47
+ "PROCESSING": 0,
48
+ "RETRYABLE": 0,
49
+ "COMPLETED": 5,
50
+ "DEAD": 1
51
+ }
52
+ }
@@ -0,0 +1,30 @@
1
+ # RabbitMQ restart and retry recovery
2
+
3
+ On 2026-10-28, the [operational harness](../scripts/test-broker-recovery.mjs) passed all five scenarios with RabbitMQ 4.4.0, PostgreSQL 16.15, and Node 24.11.1. It killed the actual RabbitMQ process with `SIGKILL` three times and restarted the same container with its persistent data volume. The successful run took 21.752 seconds, excluding the outer PostgreSQL setup. [Raw result](broker-recovery-results.json).
4
+
5
+ | Scenario | Verified result |
6
+ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | Receipt and business transaction commit, then broker dies before ACK | RabbitMQ marks the delivery as redelivered; `accept()` returns the same completed receipt as a duplicate; exactly one business effect remains |
8
+ | Admission backpressure, confirmed transfer to the retry queue, then broker dies | Persistent retry message survives; TTL dead-letter routing returns it to the main queue with retry and `x-death` evidence; processing commits one effect |
9
+ | Admission remains unavailable through the transport retry budget | Four failed acceptance attempts, three confirmed retry transfers, then transport DLQ; no receipt and no business effect were created |
10
+ | Invalid JSON or conflicting content for an existing message identity | Both are rejected to transport DLQ with `x-death.reason=rejected`; no additional business effect is committed |
11
+ | Accepted and ACKed messages fail inside the handler, then broker and Inbox service restart | Persisted Inbox retry state survives; transient failure completes on attempt 2; poison message reaches Inbox `DEAD` on attempt 3; failed attempts roll back their business writes |
12
+
13
+ The final database contains five completed receipts and one DEAD receipt, with no pending, processing, or retryable records. The main, retry, and dead-letter queues are empty after their results are inspected and acknowledged. Business-effect rows have independent UUID primary keys, so duplicate handler commits cannot be hidden by a per-receipt unique constraint.
14
+
15
+ ## Reproduce
16
+
17
+ Install the repository and example dependencies, then run:
18
+
19
+ ```sh
20
+ npm run build
21
+ node scripts/with-test-db.mjs node scripts/test-broker-recovery.mjs
22
+ ```
23
+
24
+ The script uses `amqplib` from `examples/node_modules`. The database wrapper ignores application database URLs and creates a random loopback-only PostgreSQL fixture. The broker harness creates its own random loopback-only RabbitMQ container and named volume, and removes both in `finally`. It re-reads Docker's published port after each restart because the ephemeral host port can change. It never restarts an existing broker.
25
+
26
+ ## Scope
27
+
28
+ This is a single-node RabbitMQ classic-queue test with durable queues, persistent messages, publisher confirms, and explicit manual ACKs. It verifies the application's acceptance/ACK and retry routing contract against a real broker. The harness drives each failure boundary explicitly instead of using an automatically reconnecting production consumer. Admission failure is real Inbox backpressure; this test does not substitute a simulated database outage for the separate network and database fault tests.
29
+
30
+ RabbitMQ cluster partitions, quorum-queue failover, production consumer reconnect policies, Kafka consumer-group rebalances, and AWS SQS remain deployment-specific validation. Transport DLQ and Inbox DEAD are separate states: the former contains messages that were never accepted, while the latter retains accepted messages whose handler exhausted its attempts.
@@ -0,0 +1,41 @@
1
+ # PostgreSQL failover and disk exhaustion tests
2
+
3
+ The [2026-10-28 local run](operations/2026-10-28-database.json) passed both scenarios with Node 24.22.1, Docker 29.9.2, and the `postgres:16-alpine` image. Standby promotion plus lease recovery took 1,979 ms; retry after freeing the full tablespace took 25 ms. All created containers and the private network were removed. These are single local observations, not recovery-time guarantees.
4
+
5
+ Run the isolated operational suite with a running Docker engine and installed repository dependencies:
6
+
7
+ ```sh
8
+ npm run test:operations:database
9
+ ```
10
+
11
+ The runner builds the current checkout and generates the fixture Prisma client. It ignores application `DATABASE_URL` values and creates its own random database, credentials, Docker network, and loopback-only ports. Every container is labeled and limited to one CPU, 768 MiB of memory, and a 512 MiB tmpfs PostgreSQL data directory. A separate 16 MiB tmpfs is used for the disk-full scenario. No host data directory is mounted and the test cannot fill the host filesystem.
12
+
13
+ Each command and readiness wait is bounded. The runner removes only the container IDs and network ID that it created, including on assertion failures. Its JSON report contains scenario results, timing, and cleanup status. Set `INBOX_DB_OPERATIONS_REPORT` to a new file path to preserve the same JSON; the runner refuses to overwrite an existing report.
14
+
15
+ ## Primary failure and standby promotion
16
+
17
+ The runner starts PostgreSQL 16 primary and standby servers. `pg_basebackup -R -X stream` initializes physical streaming replication. The test then creates three receipts:
18
+
19
+ - A completed receipt with committed business and outbox fixture rows.
20
+ - A claimed receipt whose handler has written business and outbox rows inside an uncommitted transaction.
21
+ - A pending receipt accepted while the other handler is paused.
22
+
23
+ Before killing the primary with `SIGKILL`, the test waits until the standby has replayed the primary's flushed WAL position covering the accepted receipts and claim. It verifies that the paused handler's uncommitted effects are invisible on the standby. The standby is then promoted using `pg_promote()`, and a new application-owned Prisma client connects to the promoted server.
24
+
25
+ The test checks that redelivery of all three messages remains a duplicate, the completed receipt is preserved, and the abandoned claim is recovered after its real lease expires. Each receipt must finish with exactly one business row and one outbox row. These fixture tables deliberately have no uniqueness constraint that could mask duplicate committed effects. The abandoned receipt must have two attempts.
26
+
27
+ This verifies recovery **after the tested WAL position has reached the standby**. Asynchronous replication can lose transactions that have not reached the failover target. The test does not claim zero data loss for arbitrary failover, automatic endpoint discovery, a high-availability control plane, or rejoining the old primary. The old primary stays stopped, preventing split brain. Applications and database infrastructure own replication durability, endpoint routing, fencing, and restoration policies.
28
+
29
+ ## Real PostgreSQL disk-full failure
30
+
31
+ A separate PostgreSQL instance has a tablespace on a **16 MiB tmpfs mount**. The test fills a ballast table with uncompressed one-MiB values until PostgreSQL returns SQLSTATE `53100` (`disk_full`). The insertion loop has a maximum of 32 attempts.
32
+
33
+ An Inbox handler writes its business and outbox fixture rows and then attempts to write to the exhausted tablespace in the same transaction. The test requires that processing becomes `RETRYABLE`, no business or outbox effect commits, and the failed tablespace write leaves no visible row. It then drops its disposable ballast table, truncates its disposable business table, retries the receipt, and verifies one committed set of effects, exactly two attempts, and duplicate handling on redelivery.
34
+
35
+ This exercises actual data-tablespace ENOSPC and recovery. WAL-volume, system-catalog-volume, inode exhaustion, cloud storage quotas, and a full host filesystem are different failure modes and are not covered here. The test intentionally leaves enough space for PostgreSQL and Inbox's retry-state storage to remain available.
36
+
37
+ ## References
38
+
39
+ - [PostgreSQL streaming replication and standby operation](https://www.postgresql.org/docs/16/warm-standby.html)
40
+ - [PostgreSQL pg_basebackup](https://www.postgresql.org/docs/16/app-pgbasebackup.html)
41
+ - [PostgreSQL error codes](https://www.postgresql.org/docs/16/errcodes-appendix.html)
@@ -0,0 +1,129 @@
1
+ # @nestarc/inbox 구현 로드맵
2
+
3
+ 기준일: 2026년 10월 27일 · 연결 문서: [패키지 기획](package-plan.md)
4
+
5
+ 첫 릴리스는 수신 저장, DB worker, 영구 재시도, DEAD 재처리를 갖춘 `0.1.0 Preview`로 제안한다. 개발 순서는 코드 양보다 데이터 손실과 중복 커밋 가능성을 먼저 검증하도록 정한다. 아래 6~8주는 숙련 개발자 1명이 주로 투입되는 경우의 계획 가정이며, 확정 일정이 아니다. 기존 패키지 재구조화는 이 출시의 선행 조건으로 두지 않는다.
6
+
7
+ Preview.1 추가 범위는 [사용법](usage.md)의 완료 메시지 replay와 tenancy adapter, [예제](../examples/README.md)의 브로커·idempotency 연동을 따른다. 기존 AC12의 COMPLETED requeue 금지는 유지하며, 새 replay API는 원본을 보존한 별도 receipt를 생성한다.
8
+
9
+ 0.2.0 후속 작업에서는 [테스트 helper](testing.md), 실제 네트워크·프로세스 종료 복구, [standby 승격과 디스크 고갈](db-operations.md), [RabbitMQ 재시작·retry·DLQ](broker-recovery.md) 검증과 디스크 기반 지속 부하 측정 도구를 추가했다. 아래 단계와 초기 착수 목록은 원래 계획이며, 최신 실행 결과는 [검증 기록](validation.md)을 따른다. **M5 실제 서비스 도입·피드백은 이번 작업에서 제외했으며 완료하지 않았다.**
10
+
11
+ ## 단계별 산출물
12
+
13
+ | 단계 | 기간 가정 | 산출물 | 다음 단계 진입 기준 |
14
+ | --- | --- | --- | --- |
15
+ | M0 계약과 기술 실험 | 1주 | identity·fingerprint·상태 전이·ACK 계약, Prisma transaction 실험 | 오래된 worker의 업무 쓰기까지 롤백되는 PostgreSQL 재현 테스트 |
16
+ | M1 수신과 저장 | 1주 | SQL schema/migration, `accept`, unique·conflict, validation | 동일 메시지 동시 수신과 commit 응답 유실 검증 |
17
+ | M2 처리와 복구 | 1~2주 | claim, lease, heartbeat, handler transaction, retry, DEAD | 프로세스 강제 종료·lease 경합·최대 시도 테스트 통과 |
18
+ | M3 Nest와 운영 | 1주 | 모듈, DI, worker 역할, 종료 처리, scoped admin, retention | tenant 격리·requeue 경합·hook 실패·정리 경합 검증 |
19
+ | M4 조합과 출시 후보 | 1주 | outbox 연동, RabbitMQ/webhook 예제, npm tarball 검증 | 실제 설치된 패키지로 end-to-end 예제 실행 |
20
+ | M5 도입 피드백 | 1~2주 | 실제 서비스 2곳 도입 목표, 부하·운영 기록 | Preview 계약 수정과 v1.0 후보 범위 결정 |
21
+
22
+ M0에서 원자성 또는 timeout 계약을 구현하지 못하면 worker API를 확장하기 전에 저장소 설계를 고친다. CLI·dashboard·추가 브로커 adapter로 일정 차이를 메우지 않는다.
23
+
24
+ ## 바로 착수할 작업
25
+
26
+ 1. `docs/adr/`에 durable acceptance 모드, DB transaction 경계, identity와 재시도 소유권을 결정 기록으로 고정한다.
27
+ 2. 최소 TypeScript·NestJS 패키지와 별도 PostgreSQL 통합 테스트 환경을 만든다.
28
+ 3. inbox row 하나와 업무 counter를 사용해 완료 CAS 실패가 업무 쓰기도 롤백하는 실험을 작성한다.
29
+ 4. worker A 정지 → lease 만료 → worker B 확보 → A 복귀 시나리오를 두 연결·두 프로세스로 재현한다.
30
+ 5. 동일 transaction을 사용하는 outbox 기록까지 롤백되는지 검증한 뒤 API와 schema를 고정한다.
31
+
32
+ ## 권장 저장소 구조
33
+
34
+ ```text
35
+ src/
36
+ index.ts
37
+ inbox.module.ts
38
+ inbox.service.ts
39
+ inbox-admin.service.ts
40
+ contracts/
41
+ storage/prisma/
42
+ worker/
43
+ internal/
44
+ sql/
45
+ test/
46
+ unit/
47
+ integration/
48
+ failure/
49
+ consumer/
50
+ examples/
51
+ outbox-to-inbox/
52
+ rabbitmq-ingress/
53
+ webhook-ingress/
54
+ docs/
55
+ package-plan.md
56
+ implementation-roadmap.md
57
+ adr/
58
+ ```
59
+
60
+ 저장소 interface는 내부 교체와 테스트를 위한 최소 경계로 둔다. 초기부터 임의 adapter를 지원하는 안정 API로 공개하지 않는다. Prisma client 생성과 연결 종료의 소유자는 사용자 앱이다. worker 종료가 끝난 뒤 앱이 client를 종료하는 순서를 예제에서 보여준다.
61
+
62
+ ## 출시를 막는 필수 검증
63
+
64
+ 아래는 출시 수용 기준이다. 현재 구현에서 각 기준을 어떤 테스트로 확인했는지와 남은 운영 검증은 [검증 기록](validation.md)에 연결했다. 데이터 경합과 복구는 실제 PostgreSQL로 확인하며 메모리 저장소만으로 대체하지 않는다.
65
+
66
+ | ID | 시나리오 | 통과 기준 |
67
+ | --- | --- | --- |
68
+ | AC01 | 같은 identity·payload를 100개 요청으로 동시 수신 | 레코드 1개, 성공한 업무 변경 1회, 나머지는 duplicate |
69
+ | AC02 | messageId는 같고 tenant·source·consumer가 다름 | 각 identity는 독립 처리, 다른 범위의 기록 노출 없음 |
70
+ | AC03 | 같은 identity로 다른 payload 수신 | conflict, 기존 payload·fingerprint·상태 불변 |
71
+ | AC04 | 업무 쓰기 뒤 handler 예외 | 업무 변경과 완료·outbox 모두 롤백, retry 일정 영구 저장 |
72
+ | AC05 | 수신 commit 직후 ACK 전 프로세스 종료 | 재전달이 duplicate가 되고 inbox가 계속 처리 |
73
+ | AC06 | claim 직후 프로세스 강제 종료 | lease 만료 뒤 재확보, 시도 예산 증가, 메시지 손실 없음 |
74
+ | AC07 | 이전 worker 정지 후 새 worker claim, 이전 worker 복귀 | 이전 token의 완료·갱신·실패 쓰기 거절, 이전 업무 transaction 롤백 |
75
+ | AC08 | 완료 CAS 직후 lease 만료와 reclaim 경합 | row lock과 상태 재검사로 업무 이중 커밋 없음 |
76
+ | AC09 | 커밋 응답 유실 또는 DB 연결 단절 | 완료를 실패로 덮어쓰지 않음, 재확인/lease 복구 후 수렴 |
77
+ | AC10 | 계속 실패하거나 crash하는 메시지 | 최초 실행 포함 설정 횟수까지만 시도하고 DEAD |
78
+ | AC11 | DEAD requeue 동시 요청과 오래된 expectedVersion | 한 회차만 생성, lifetime attempt·token 유지, 원 payload 불변 |
79
+ | AC12 | COMPLETED requeue 요청 | 명시적으로 거절, 업무 재실행 없음 |
80
+ | AC13 | 담당 handler 누락·미지원 schema·중복 consumer | 담당 등록 오류는 시작 실패, 담당 consumer의 처리 불가 schema는 사유와 함께 DEAD |
81
+ | AC14 | 다른 tenant의 ID로 조회·requeue | tenant 범위 바깥 읽기·쓰기 거절 |
82
+ | AC15 | 성공 payload 정리와 오래된 메시지 재전달 | identity·fingerprint가 남아 재처리되지 않음 |
83
+ | AC16 | pending·processing·retryable·dead에 정리 작업 실행 | 자동 삭제로 재처리 자료가 사라지지 않음 |
84
+ | AC17 | metrics·log·hook 예외 | 이미 커밋된 성공에 영향 없음, payload·비밀 header 미노출 |
85
+ | AC18 | 신규 claim 중단과 종료 drain | 제한 시간 내 진행 작업만 마무리, 나머지는 재확보 가능 |
86
+ | AC19 | payload 상한·비정상 JSON·identity 길이 초과·DB 용량 제한 | 조용한 절단·성공 ACK 없이 typed error 반환 |
87
+ | AC20 | inbox → 업무 쓰기 → outbox → downstream 중복 수신 | 소비자별 성공한 DB 효과 1회, 발행 재시도가 별도로 동작 |
88
+ | AC21 | ingress와 worker의 분리 배포·DB pool 포화 | 담당 consumer만 claim, 타 consumer를 DEAD로 만들지 않음, 가용 slot 이상 선점하지 않음 |
89
+ | AC22 | transaction timeout·SQL timeout·오래 멈춘 handler | 원자성 유지, 늦은 쓰기 실패, 무한 heartbeat로 영구 선점하지 않음 |
90
+ | AC23 | consumer를 담당하는 worker가 배포에서 사라짐 | backlog를 조용히 완료·폐기하지 않음, 지연 경보와 복구 절차로 탐지 |
91
+ | AC24 | 완료 CAS 성공 후 commit 전 프로세스 종료 | 업무 변경·outbox·완료가 함께 롤백되고 이후 재확보 가능 |
92
+ | AC25 | lease 만료 후 heartbeat와 DEAD requeue 후 이전 worker의 실패 쓰기 | 상태·token·시간 조건으로 소유권 부활과 새 PENDING 덮어쓰기 거절 |
93
+
94
+ fingerprint unit test에는 JSON key 순서, 배열 순서, 숫자·문자열 구분, 중첩 값, 가변 trace metadata 제외, 알고리즘 버전 변경을 포함한다. 업무 중복 unique constraint와 순서 역전은 앱의 책임임을 예제에서 보여준다.
95
+
96
+ ## 호환성과 패키징 검증
97
+
98
+ 첫 CI 후보는 Node 22·24, NestJS 11, Prisma 7, PostgreSQL 16·18이다. 실제로 통과한 조합만 지원 문서에 올린다. dependency의 최신 버전 번호와 API는 구현 착수 시 registry·해당 major 문서를 다시 확인해 고정한다.
99
+
100
+ - `npm pack` 결과를 빈 consumer 앱에 설치해 import, 타입, 런타임을 확인한다.
101
+ - outbox와 동일한 Prisma transaction을 공유하는 예제를 실제 DB에서 실행한다.
102
+ - 배포 SQL 자산이 tarball에 포함되고 fresh install과 upgrade가 동작하는지 확인한다.
103
+ - 런타임 schema 검사에서 잘못된 migration version을 감지한다.
104
+ - 앱의 Prisma migration과 inbox SQL 테이블 소유권이 충돌하지 않는 절차를 제공한다.
105
+ - queue SDK와 다른 Nestarc 패키지가 필수 runtime dependency로 들어오지 않는지 확인한다.
106
+
107
+ 문서에는 ACK 시점, retry 책임, handler 제한, DEAD 복구, 데이터 보존, 지원 버전, DB 복원 시 중복 제거 상태도 과거로 돌아간다는 운영 조건을 포함한다. 예제에 설치되지 않은 기능이나 보장하지 않는 exactly-once 문구를 쓰지 않는다.
108
+
109
+ ## 성능과 운영 검증
110
+
111
+ 성능 수치를 출시 전에 만들어 약속하지 않는다. 재현 가능한 benchmark에서 DB 사양·버전·동시성·payload 크기·연결 수·행 수를 함께 기록한다. 빈 테이블뿐 아니라 완료 기록이 쌓인 상태와 중복 수신이 많은 상태도 측정한다.
112
+
113
+ 측정 항목은 accept p50/p95/p99, 초당 수신 수, claim 지연, 가장 오래된 대기 시간, 처리 지연, SQL 수, pool 대기, retry 증가 시 backlog 회복 시간이다. 완료 기록의 증가량과 인덱스 크기를 측정해 retention 지침을 만든다.
114
+
115
+ 첫 운영 설정은 제한된 동시성과 bounded batch를 사용한다. DB pool에는 heartbeat·상태 전환을 수행할 여유를 남긴다. 특정 tenant의 작업 집중이 다른 tenant를 얼마나 지연시키는지 기록하고, 공정성 스케줄러 도입은 실제 필요가 확인된 뒤 결정한다.
116
+
117
+ ## 구현 전에 확정할 세부 결정
118
+
119
+ | 결정 | 현재 제안 | 확정 방법 |
120
+ | --- | --- | --- |
121
+ | lease·heartbeat·transaction 제한 | 짧은 DB 처리용 bounded timeout, heartbeat에 최대 수명 적용 | M0·M2 장애 실험 |
122
+ | retry 기본값 | 총 8회, 1초 기반, 5분 상한, full jitter | 실패 유형과 backlog 회복 측정 |
123
+ | payload와 metadata 상한 | payload 256 KiB, 명시적 JSON·header 허용 목록 | 실제 이벤트 예제와 DB 부하 확인 |
124
+ | fingerprint 규칙 | 의미 envelope의 정규화 JSON, 알고리즘 버전 저장 | golden vector와 upgrade 테스트 |
125
+ | 저장 schema와 인덱스 | 복합 unique, due·lease expiry 부분 인덱스 | 경쟁 실행과 쿼리 계획 확인 |
126
+ | consumer 배포 계약 | ingress와 worker가 호환되는 event/schema 정의 공유 | handler 제거·이전 schema backlog 테스트 |
127
+ | tenant transaction 연결 | 신뢰 가능한 scope 전달, 동일 tx에 앱의 접근 정책 적용 | 격리 테스트와 선택적 tenancy 조합 |
128
+
129
+ 기능이 늘어나도 `accept()`의 커밋 경계, 같은 DB transaction의 완료 보장, retry 단일 소유권은 유지한다. 이 계약을 바꾸는 기능은 별도 설계 결정으로 검토한다.
@@ -0,0 +1,34 @@
1
+ {
2
+ "node": "v24.22.1",
3
+ "image": "postgres:16-alpine",
4
+ "startedAt": "2026-10-28T13:17:04.937Z",
5
+ "scenarios": [
6
+ {
7
+ "scenario": "streaming-standby-promotion",
8
+ "passed": true,
9
+ "acceptedReceipts": 3,
10
+ "committedBusinessEffects": 3,
11
+ "committedOutboxEffects": 3,
12
+ "recoveryMs": 1979,
13
+ "replayedBeforePrimaryKill": "0/30070D8",
14
+ "replicationBoundary": "explicitly awaited standby replay before primary SIGKILL; not a claim of zero-loss asynchronous replication"
15
+ },
16
+ {
17
+ "scenario": "tablespace-enospc-recovery",
18
+ "passed": true,
19
+ "filesystemLimitBytes": 16777216,
20
+ "fillerRows": 15,
21
+ "postgresSqlstate": "53100",
22
+ "failedAttemptEffects": 0,
23
+ "committedBusinessEffects": 1,
24
+ "committedOutboxEffects": 1,
25
+ "attempts": 2,
26
+ "recoveryMs": 25,
27
+ "scope": "business data tablespace ENOSPC; WAL/system-volume exhaustion is not covered"
28
+ }
29
+ ],
30
+ "docker": "29.9.2",
31
+ "passed": true,
32
+ "cleanedUp": true,
33
+ "finishedAt": "2026-10-28T13:17:11.779Z"
34
+ }
@@ -0,0 +1,250 @@
1
+ # @nestarc/inbox 패키지 기획
2
+
3
+ 작성일: 2026년 10월 27일 · 상태: MVP 구현 기준 · 첫 공개 목표: `0.1.0 Preview`
4
+
5
+ 현재 구현과 실제 실행한 검증 범위는 [사용법](usage.md)과 [검증 기록](validation.md)을 따른다. 이 문서는 초기 제품 결정과 후속 개발 범위를 보존한다.
6
+
7
+ 2026-10-28 Preview.1에서는 초기 후속 항목 중 완료 메시지의 별도 receipt replay, tenancy adapter와 실제 RLS 검증, Kafka·SQS 호환·BullMQ 수신 예제, idempotency PostgreSQL 조합을 추가했다. JSON/hash·retry·취소 primitive는 private source workspace로 추출했다. 다른 저장소의 패키지를 이 workspace로 이전한 것은 아니며 lease/fencing SQL은 저장소 구현에 남긴다. 아래 초기 MVP의 제한은 이 변경 이력과 함께 읽는다.
8
+
9
+ `@nestarc/inbox`는 NestJS 애플리케이션이 받은 메시지를 PostgreSQL에 저장하고, 중복 수신과 장애 후 재시도를 관리하는 패키지다. 첫 버전은 Prisma를 사용하는 짧은 DB 업무 처리에 집중한다. **업무 변경, inbox 처리 완료, 선택적인 outbox 이벤트 저장을 같은 트랜잭션으로 커밋하는 것**이 핵심 가치다.
10
+
11
+ 공개 패키지는 하나로 시작한다. 브로커에서 받은 메시지를 저장하는 `accept()`와 저장된 메시지를 처리하는 worker를 제공하고, 범용 작업 큐·워크플로·브로커 클라이언트는 기존 도구에 맡긴다.
12
+
13
+ ## 리서치에서 채택한 방향
14
+
15
+ [원 리서치 대화](https://chatgpt.com/c/6ae098e4-d354-83ee-a184-f0801b1f80fa)의 「nestarc.dev 패키지 포트폴리오 유지·중단·신규 개발 검토 보고서」는 inbox를 신규 개발 최우선으로 추천한다. 보고서의 95점은 잠정 전략 점수다. 다운로드·외부 도입·유지보수 지표를 모두 확보한 시장 검증 결과로 해석하지 않는다.
16
+
17
+ | 리서치 제안 | 이번 기획에 반영할 결정 |
18
+ | --- | --- |
19
+ | 수신 이벤트의 영구 중복 제거와 재처리 | 메시지 저장부터 완료·실패 격리까지 inbox가 관리 |
20
+ | outbox와 연결되는 신뢰성 계층 | 소비자 DB 트랜잭션 안에서 `outbox.emit(tx, ...)` 사용 |
21
+ | jobs의 범용 큐 기능 확대를 줄임 | cron, DAG, 우선순위 큐, 브로커 운영 기능은 제외 |
22
+ | lease와 fencing으로 오래된 작업자의 변경 차단 | 소유권을 잃은 worker의 완료 갱신과 업무 커밋을 차단 |
23
+ | reliability-core는 내부에서 검증 | 초기에는 `src/internal`로 구현하고 별도 공개 패키지 보류 |
24
+
25
+ 이 방향에는 기존 코드의 근거도 있다. outbox의 [quick-start](https://github.com/nestarc/outbox/blob/main/examples/quick-start/README.md)는 소비자 처리 기록과 DB 변경을 하나의 트랜잭션으로 저장하는 코드를 애플리케이션이 작성하게 한다. idempotency의 [adoption recipes](https://github.com/nestarc/idempotency/blob/main/docs/adoption-recipes.md)도 이벤트 수신 기록, 업무 중복 방지, 순서 처리를 구별한다. inbox는 이 중 반복되는 수신 기록과 처리 복구를 패키지화한다.
26
+
27
+ ## 목표 사용자와 해결할 문제
28
+
29
+ 첫 사용자는 NestJS·Prisma·PostgreSQL을 이미 사용하고, 외부 이벤트 또는 outbox 이벤트로 업무 데이터를 변경하는 개발자다. 처음 검증할 시나리오는 주문 이벤트를 받아 주문 projection을 갱신하고 후속 이벤트를 만드는 서비스다. 두 번째는 서명 검증을 마친 webhook을 저장한 뒤 비동기로 반영하는 서비스다.
30
+
31
+ 사용자는 다음 작업을 직접 구현하지 않아도 되어야 한다.
32
+
33
+ - 같은 메시지가 여러 인스턴스에 동시에 도착해도 수신 기록을 하나만 만든다.
34
+ - 수신 저장 후 프로세스가 종료되어도 메시지를 다시 처리한다.
35
+ - 실패한 업무 변경은 롤백하고, 재시도 횟수와 다음 실행 시각은 영구 저장한다.
36
+ - 반복 실패를 격리하고, 원인을 수정한 뒤 명시적으로 재처리한다.
37
+ - 처리 기록과 실제 업무 데이터가 서로 다른 결과로 커밋되는 일을 막는다.
38
+
39
+ **메시지 ID가 다른 두 이벤트가 동일한 주문을 생성하는 문제**는 업무 테이블의 unique constraint로 해결한다. 이벤트 도착 순서 역전은 업무 버전 검사 등으로 해결한다. inbox의 메시지 중복 제거가 이 두 문제까지 해결하지는 않는다.
40
+
41
+ ## Nestarc 패키지 사이의 책임
42
+
43
+ | 패키지 | 소유하는 책임 | inbox와의 연결 |
44
+ | --- | --- | --- |
45
+ | `idempotency` | HTTP 요청 중복 검사와 응답 재생 | HTTP 경계에서 선택적으로 조합 |
46
+ | `inbox` | 수신 메시지 저장, 중복 제거, 처리 상태와 재시도 | 이번 개발 대상 |
47
+ | `outbox` | 업무 변경과 발신 이벤트 저장의 원자성, 발행 재시도 | inbox handler의 `tx`로 후속 이벤트 저장 |
48
+ | `webhook` | 외부 endpoint로 webhook 발신 | outbox 이후 외부 전달 경로 |
49
+ | `jobs` 또는 기존 큐 | 작업 전달·스케줄·브로커 실행 | inbox에 메시지를 전달하는 기존 경로 |
50
+ | `tenancy` | tenant context와 데이터 접근 격리 | 선택적 연동, 필수 의존성 제외 |
51
+
52
+ 연결 근거: [outbox](https://nestarc.dev/packages/outbox/), [idempotency](https://nestarc.dev/packages/idempotency/), [jobs](https://github.com/nestarc/jobs/blob/main/README.md), [webhook](https://github.com/nestarc/webhook/blob/main/README.md).
53
+
54
+ ## 첫 버전의 처리 방식
55
+
56
+ **영구 수신 후 비동기 처리 모드 하나를 제공한다.** 더 작은 대안인 caller transaction 기반 중복 검사만으로 시작하면 재시도와 실패 격리는 브로커에 남는다. 이번 기획은 리서치가 제안한 자체 재처리를 포함하므로 두 모드를 동시에 공개하지 않는다.
57
+
58
+ ```mermaid
59
+ sequenceDiagram
60
+ participant B as 브로커 또는 webhook
61
+ participant A as 애플리케이션 수신부
62
+ participant I as Inbox
63
+ participant D as PostgreSQL
64
+ participant W as Inbox worker
65
+ B->>A: 메시지 전달
66
+ A->>A: 인증과 입력 검증
67
+ A->>I: accept(identity, payload)
68
+ I->>D: 트랜잭션 A에서 수신 기록 저장
69
+ D-->>I: COMMIT
70
+ I-->>A: accepted 또는 duplicate
71
+ A-->>B: ACK 또는 HTTP 2xx
72
+ W->>D: 트랜잭션 B에서 claim과 lease 확보
73
+ W->>D: 트랜잭션 C에서 업무 변경
74
+ W->>D: 선택적 outbox 저장과 fenced 완료 갱신
75
+ D-->>W: 모두 COMMIT 또는 모두 ROLLBACK
76
+ ```
77
+
78
+ `accept()`는 자신이 연 트랜잭션의 커밋을 확인한 뒤에만 반환한다. 외부 transaction을 받는 overload는 첫 버전에 제공하지 않는다. ACK를 너무 일찍 보내는 실수를 줄이기 위해서다. `accepted`는 업무 처리 완료를 뜻하지 않는다.
79
+
80
+ | 상황 | 수신부의 처리 | 재시도 책임 |
81
+ | --- | --- | --- |
82
+ | 새 수신 기록 커밋 완료 | ACK 또는 2xx | inbox |
83
+ | 같은 identity와 fingerprint가 이미 저장됨 | 현재 상태와 함께 duplicate 반환, ACK 또는 2xx | inbox, DEAD는 운영자 조치 |
84
+ | DB 실패 또는 커밋 결과 불확실 | 성공 ACK 금지 | 원 발신자나 브로커 재전달 |
85
+ | 같은 identity에 다른 내용 | conflict로 거절, 원래 레코드 보존 | 애플리케이션의 명시적 오류·DLQ 정책 |
86
+ | 인증 실패, 잘못된 envelope, 크기 초과 | 저장 전 거절 | 애플리케이션의 오류·DLQ 정책 |
87
+
88
+ NestJS RabbitMQ의 수동 ACK 설정은 [공식 문서](https://docs.nestjs.com/microservices/rabbitmq#message-acknowledgement)를 따른다. 첫 runnable 브로커 예제는 RabbitMQ 하나로 제한한다. Kafka offset의 연속 커밋, SQS visibility, BullMQ 완료 처리는 후속 예제로 검증한 뒤 안내한다.
89
+
90
+ ## MVP 범위
91
+
92
+ | 영역 | 0.1 Preview에 포함 | 이후 수요에 따라 검토 |
93
+ | --- | --- | --- |
94
+ | 저장소 | PostgreSQL과 Prisma, SQL migration, schema 검증 | 다른 ORM과 DB |
95
+ | 수신 | `accept`, 복합 identity, 내용 충돌 검사, JSON 크기 제한 | batch 수신 |
96
+ | worker | DB polling, 제한된 동시성, claim, lease 갱신, fencing | 고급 스케줄·공정성 정책 |
97
+ | 업무 처리 | 전달받은 `tx`에서 업무 변경과 완료를 원자적으로 커밋 | caller transaction 기반 동기 처리 모드 |
98
+ | 실패 | 영구 retry 일정, backoff와 jitter, 최대 시도, DEAD | 별도 quarantine UI |
99
+ | 운영 | 범위를 지정하는 조회, DEAD requeue, 통계, 종료 처리 | 대시보드와 HTTP 관리 controller |
100
+ | 조합 | outbox 연동 예제, tenant scope 계약 | tenancy 전용 편의 adapter |
101
+ | 관측 | 구조화된 로그와 hooks, 처리 지연·상태 수치 | 전용 metrics exporter |
102
+
103
+ 외부 HTTP 호출, 이메일, 결제 실행의 exactly-once, strict FIFO, saga, workflow, cron, HMAC 검증, HTTP 응답 캐시, 브로커 SDK는 범위에 넣지 않는다. worker는 저장된 수신 메시지의 복구에 필요한 기능만 가진다.
104
+
105
+ ## 처리 보장
106
+
107
+ 다음은 구현과 장애 테스트가 만족해야 하는 제품 계약이다.
108
+
109
+ 1. 같은 identity의 레코드는 DB unique constraint로 하나만 존재한다.
110
+ 2. handler는 장애 때문에 여러 번 호출될 수 있다. **같은 레코드에 대해 업무 DB 변경과 완료 기록이 함께 성공하는 트랜잭션은 최대 한 번**이어야 한다.
111
+ 3. 2번 보장은 handler의 모든 쓰기가 전달받은 동일 DB transaction을 사용하고, 완료 기록이 보존될 때 성립한다. 별도 Prisma client·별도 DB·외부 API 쓰기는 포함하지 않는다.
112
+ 4. 오래된 worker의 완료 갱신이 거절되면 업무 변경과 outbox 쓰기도 전부 롤백한다.
113
+ 5. 성공은 DB 커밋으로 결정한다. 로그·hook 실패가 성공 레코드를 실패로 되돌리지 않는다.
114
+ 6. 반복 실패는 DEAD에 남는다. 유한한 재시도 정책이 모든 메시지의 최종 성공을 보장하지는 않는다.
115
+ 7. 완료 기록을 삭제하면 과거 메시지를 다시 받아 처리할 수 있다. 보존 기간은 중복 제거의 보장 기간이다.
116
+
117
+ 외부 부작용이 필요하면 handler는 같은 `tx`로 outbox를 기록한다. 그 뒤 외부 전달에는 수신 서비스의 durable idempotency key 등 별도 계약이 필요하다.
118
+
119
+ ## 메시지 identity와 저장 모델
120
+
121
+ 중복 제거 키는 `(scopeKind, scopeId, source, consumer, messageId)`다. 모든 열은 NOT NULL이다. tenant scope는 `scopeKind=tenant`와 tenant ID를 사용하고, 전역 작업은 `scopeKind=global, scopeId=''` 조합만 허용한다. 빈 tenant ID는 거절한다. SQL NULL의 unique 처리와 단순 문자열 이어붙이기에 의존하지 않는다. [PostgreSQL 제약 조건](https://www.postgresql.org/docs/current/ddl-constraints.html)
122
+
123
+ - `source`: 발행 서비스 또는 provider account까지 구분하는 안정적인 이름.
124
+ - `consumer`: 인스턴스 ID가 아닌 논리적 처리기 이름. 예: `orders.projection.v1`.
125
+ - `messageId`: 재전달에도 유지되는 발행자 ID. outbox 연동에서는 이벤트 record ID를 사용한다.
126
+ - `scope`: 인증된 수신 경로나 앱의 신뢰 가능한 매핑에서 결정한다. 검증하지 않은 payload의 tenant ID를 그대로 신뢰하지 않는다.
127
+
128
+ broker delivery tag, 시도 번호, 매번 생성한 UUID는 messageId로 쓰지 않는다. tenant·source·consumer 각각 UTF-8 128바이트, messageId 256바이트의 초기 상한을 두고 초과 입력을 거절하는 안으로 시작한다.
129
+
130
+ consumer 이름을 바꾸면 별도 소비자로 인식되어 같은 메시지를 다시 처리할 수 있다. 배포 버전이나 클래스 이름 변경으로 consumer identity가 자동 변경되지 않게 한다.
131
+
132
+ `eventType`, `schemaVersion`, 업무 `payload`의 정규화 JSON으로 fingerprint를 만든다. JSON key 순서 차이는 같은 내용으로 보며 알고리즘 버전을 저장한다. 서명·수신 시각·trace header처럼 재전달마다 달라질 수 있는 값은 제외한다. 업무 판단에 필요한 값은 payload에 둔다. 같은 identity에서 fingerprint가 다르면 어떤 상태에서도 기존 내용을 덮어쓰지 않는다.
133
+
134
+ | 필드군 | 제안 필드 |
135
+ | --- | --- |
136
+ | 식별 | `id`, 복합 identity 5개 열 |
137
+ | 내용 | `event_type`, `schema_version`, `payload`, `fingerprint`, `fingerprint_version` |
138
+ | 상태 | `status`, `received_at`, `completed_at`, `next_attempt_at` |
139
+ | 소유권 | `claim_token`, `lease_expires_at`, 진단용 `worker_id` |
140
+ | 시도 | `attempt_count` 누적, `cycle_attempt_count` 재처리 회차별, `requeue_count` |
141
+ | 오류·운영 | 정제된 `last_error_code`, `last_error_message`, `row_version`, 마지막 requeue 사유·실행자·시각 |
142
+ | 연계 | `correlation_id`, `causation_id`, 제한된 trace metadata |
143
+
144
+ 기본 payload 상한 후보는 256 KiB다. header 허용 목록·오류 문자열 길이·JSON 검증 규칙도 제한한다. identity와 fingerprint는 payload를 지운 뒤에도 보존한다.
145
+
146
+ ## 상태와 장애 복구
147
+
148
+ ```mermaid
149
+ stateDiagram-v2
150
+ [*] --> PENDING: 수신 저장
151
+ PENDING --> PROCESSING: claim
152
+ PROCESSING --> COMPLETED: 업무와 함께 커밋
153
+ PROCESSING --> RETRYABLE: 실패와 잔여 시도
154
+ RETRYABLE --> PROCESSING: 다음 실행 시각 도달
155
+ PROCESSING --> PROCESSING: lease 만료 후 새 token으로 재확보
156
+ PROCESSING --> DEAD: 비재시도 오류 또는 시도 소진
157
+ DEAD --> PENDING: 운영자의 명시적 requeue
158
+ ```
159
+
160
+ claim은 짧은 트랜잭션에서 `FOR UPDATE SKIP LOCKED`와 조건부 갱신을 사용한다. 실제 가용 worker slot만큼만 확보한다. claim마다 token과 시도 횟수를 증가시키므로 프로세스 종료도 시도 예산을 소비한다. 만료 레코드가 이미 예산을 소진했다면 다음 handler를 실행하지 않고 DEAD로 전환한다. [PostgreSQL SELECT](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE)
161
+
162
+ 업무 transaction 끝에서 `id + PROCESSING + claim_token + 유효한 lease`를 조건으로 완료 상태를 갱신한다. 영향받은 행이 0개면 반드시 throw하여 업무 변경과 outbox 기록을 롤백한다. 성공한 완료 갱신은 commit까지 행 잠금을 유지하므로 그 사이 lease 시각이 지나도 새 worker가 이중 완료할 수 없다.
163
+
164
+ heartbeat와 실패 처리는 `PROCESSING + 현재 token + 유효 lease`, 만료 회수는 `PROCESSING + 관찰한 token + 만료 lease`, requeue는 `DEAD + expectedVersion`을 조건으로 한다. 상태 전이마다 `row_version`을 증가시킨다. 만료 heartbeat의 소유권 부활과 이전 worker의 실패 쓰기가 requeue된 PENDING을 덮어쓰는 것을 모두 거절한다.
165
+
166
+ 시간 판단은 DB 시계를 사용한다. transaction 시작 시각에 고정되는 `now()` 대신 실제 현재 시각인 `clock_timestamp()`를 lease 검사에 사용한다. [PostgreSQL 시간 함수](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-CURRENT)
167
+
168
+ 기본 재시도 후보는 최초 실행 포함 8회, 1초 기반 지수 backoff, 최대 5분, full jitter다. 정확한 기본값은 장애 실험 후 확정한다. 일반 오류는 재시도하고 `InboxPermanentError`는 즉시 DEAD로 분류한다. 담당 consumer의 미지원 event/schema는 사유와 함께 DEAD로 보존한다.
169
+
170
+ worker의 claim·만료 회수·시도 소진 처리는 모두 명시적으로 담당하는 consumer로 제한한다. 다른 worker의 consumer를 미등록이라는 이유로 DEAD로 바꾸지 않는다. 담당 목록에 handler가 없거나 중복 등록된 경우 시작을 거절한다. ingress는 설정된 consumer만 받아들이며, 배포 변경으로 담당 worker가 사라진 backlog는 대기 상태와 지연 경보로 드러낸다. handler 제거 전에 기존 backlog를 처리하거나 이전하는 절차가 필요하다.
171
+
172
+ 커밋 응답이 끊긴 경우 새 연결에서 상태를 확인한다. 확인할 수 없다면 성공·실패를 추정해 덮어쓰지 않고 lease 복구에 맡긴다. lease는 실행 중인 JavaScript나 열린 DB transaction을 강제 중단하지 못하므로 SQL·transaction·idle timeout과 종료 drain 정책을 함께 검증한다.
173
+
174
+ ## 공개 API 초안
175
+
176
+ 아래 이름과 타입은 기획 단계의 설계 예시다. 현재 Preview의 정확한 옵션과 등록 방식은 [사용법](usage.md)을 따른다. Nest 모듈은 `forRoot`와 `forRootAsync`, 앱 소유 Prisma client, 명시적 consumer 등록을 지원한다. ingress 프로세스는 worker를 끄고, worker 프로세스는 필요한 consumer 목록만 실행할 수 있다.
177
+
178
+ ```ts
179
+ // 수신부: 인증과 payload 검증 후 호출한다.
180
+ const receipt = await inbox.accept({
181
+ scope: { kind: 'tenant', id: verifiedTenantId },
182
+ source: 'orders-service',
183
+ consumer: 'orders.projection.v1',
184
+ messageId: event.id,
185
+ eventType: 'order.created',
186
+ schemaVersion: 1,
187
+ payload: event.payload,
188
+ });
189
+ // accepted 또는 내용이 같은 duplicate만 여기까지 반환한다.
190
+ // 이 시점에는 수신 저장이 커밋되었으며 업무 처리는 아직 진행 중일 수 있다.
191
+ channel.ack(message);
192
+ ```
193
+
194
+ ```ts
195
+ // Nest provider로 등록할 handler 계약의 예시. tx는 Prisma 7 형태다.
196
+ async handle(payload: OrderCreatedPayload, ctx: InboxContext<Prisma.TransactionClient>) {
197
+ if (ctx.scope.kind !== 'tenant') {
198
+ throw new InboxPermanentError('Tenant scope is required');
199
+ }
200
+ await ctx.tx.orderProjection.create({
201
+ data: { tenantId: ctx.scope.id, orderId: payload.orderId },
202
+ });
203
+
204
+ await this.outbox.emit(
205
+ ctx.tx,
206
+ new OrderProjectedEvent(payload.orderId),
207
+ { tenantId: ctx.scope.id, causationId: ctx.messageId },
208
+ );
209
+ // COMPLETED 갱신과 commit/rollback은 inbox가 수행한다.
210
+ }
211
+ ```
212
+
213
+ 예제의 `OrderCreatedPayload`, `OrderProjectedEvent`, Prisma 업무 모델은 사용자 앱이 정의한다. 실제 outbox의 transaction 전달 방식은 [OrderService 예제](https://github.com/nestarc/outbox/blob/main/examples/quick-start/src/order.service.ts)를 따른다. handler 내부에서 transaction 오류를 삼키거나 네트워크 부작용을 실행하지 않는 계약을 명시한다.
214
+
215
+ | API 후보 | 계약 |
216
+ | --- | --- |
217
+ | `InboxService.accept(envelope)` | `{ kind: accepted 또는 duplicate, id, status }`; conflict·입력·저장 오류는 typed error |
218
+ | `InboxHandler<TPayload, TTx>` | 안정적 consumer 이름, 지원 event/schema, payload 검증, `handle(payload, context)` |
219
+ | `InboxAdminService.forTenant(id)` | 해당 tenant 범위만 조회·재처리 |
220
+ | `InboxAdminService.forGlobal()` | 전역 scope만 관리; 모든 tenant 접근과 구별 |
221
+ | 별도 privileged admin provider | 명시적으로 구성했을 때만 전체 tenant 운영 |
222
+ | `admin.get`, `admin.list`, `admin.requeue` | pagination과 상태·version 검사; DEAD만 requeue |
223
+
224
+ DEAD requeue는 사유·실행자·`expectedVersion`을 요구한다. lifetime attempt와 claim token은 유지하고, 회차 시도 수만 초기화한다. 중복 운영 요청은 version 충돌로 추가 실행을 만들지 않는다. COMPLETED 재실행과 payload 변경은 제공하지 않는다. 보정은 별도 업무 이벤트로 처리한다.
225
+
226
+ ## 운영과 데이터 보존
227
+
228
+ API 서버는 수신 저장을 담당하고, worker는 앱이 실행하는 Nest 프로세스 안에서 동작한다. 라이브러리가 별도 호스팅 서비스나 daemon을 생성하지 않는다. PostgreSQL을 실제 수신 backlog로 사용하므로 backlog 수·가장 오래된 대기 시간·DEAD 증가·DB pool 포화를 운영 지표로 제공한다.
229
+
230
+ 성공 payload 정리는 완료 기록 삭제와 분리한다. 기본적으로 완료 identity와 fingerprint의 자동 삭제는 끈다. 완료 payload는 설정된 기간 후 배치 정리할 수 있다. PENDING·RETRYABLE·PROCESSING과 DEAD payload는 자동으로 삭제하지 않는다. DEAD payload 삭제·보존 기간 축소는 재처리 가능성을 바꾸므로 후속 운영 정책으로 다룬다.
231
+
232
+ 수신 저장 전 용량 제한에 걸리면 backpressure 오류를 반환한다. 저장하지 않은 메시지를 성공으로 응답하지 않는다. 로그에는 payload·비밀 header를 기본 출력하지 않고, metrics label에는 message ID·tenant ID 같은 무제한 값을 넣지 않는다. hooks는 커밋 후 best-effort 알림이며 영구 감사 기록이 아니다.
233
+
234
+ tenant scope는 중복 키와 관리 범위를 정한다. RLS와 동등한 보장을 자동으로 제공하지 않는다. tenant 데이터 접근 정책은 handler의 같은 transaction에 적용해야 한다. tenancy 연동 예제와 격리 테스트를 통과하기 전에는 전용 RLS 지원을 홍보하지 않는다.
235
+
236
+ ## 배포와 지원 범위
237
+
238
+ 첫 호환성 검증 목표는 Node 22·24, NestJS 11, Prisma 7, PostgreSQL 16·18이다. 이는 지원 선언이 아닌 테스트 후보이며 실제 설치·실행을 통과한 조합만 package peer 범위와 문서에 선언한다. 이후 NestJS 12, Prisma 6·8 등의 지원을 수요와 테스트 비용에 따라 추가한다.
239
+
240
+ [outbox package.json](https://github.com/nestarc/outbox/blob/main/package.json)의 넓은 peer 범위를 그대로 복사하지 않는다. 특히 [현재 Prisma 트랜잭션 문서](https://www.prisma.io/docs/orm/fundamentals/transactions)는 ORM 8의 API를 설명하므로 Prisma 7 예제와 섞지 않는다. 같은 client·transaction을 inbox와 outbox가 실제로 공유하는 조합을 고정해 검증한다.
241
+
242
+ SQL migration은 사용자가 배포 단계에서 명시적으로 실행한다. 런타임은 schema version을 검사하고 맞지 않으면 시작을 거절한다. 자동 DDL과 DMMF 의존은 제외한다. 패키지 SQL 자산과 앱의 Prisma migration이 함께 관리되는 방법도 예제로 제공한다.
243
+
244
+ ## 성공 기준과 후속 개발
245
+
246
+ Preview 출시는 [구현 로드맵](implementation-roadmap.md)의 장애·원자성 테스트와 packed consumer 예제가 통과할 때 진행한다. 첫 제품 검증 목표는 외부 또는 실제 서비스 2곳에서 서로 다른 수신 경로로 도입하고, 중복·재시도·DEAD 복구 흐름을 운영해 보는 것이다. 이는 현재 확보한 사용자 수가 아닌 목표다.
247
+
248
+ 초기 피드백에서 확인할 것은 도입 소요 시간, 대체한 애플리케이션 코드, 처리 지연, DB 부하, 운영자의 복구 가능성이다. 단순 npm 다운로드보다 실제 적용 사례를 우선한다. v1.0은 상태 전이·identity·migration·지원 버전 정책이 안정되고 장애 복구를 검증한 뒤 판단한다.
249
+
250
+ 초기 후속 우선순위였던 브로커 예제와 tenancy adapter는 0.1.0에, [테스트 helper](testing.md)는 0.2.0에 포함했다. 실제 서비스 2곳 도입·피드백은 이번 후속 작업에서 제외했으며 아직 목표로 남는다. 별도 public reliability-core와 다중 ORM은 실제로 중복되는 계약과 사용자 수요가 확인된 뒤 결정한다.