@nestarc/inbox 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +51 -0
- package/LICENSE +21 -0
- package/README.md +295 -2
- package/dist/contracts/errors.d.ts +34 -0
- package/dist/contracts/errors.js +73 -0
- package/dist/contracts/errors.js.map +1 -0
- package/dist/contracts/types.d.ts +174 -0
- package/dist/contracts/types.js +3 -0
- package/dist/contracts/types.js.map +1 -0
- package/dist/inbox-admin.service.d.ts +44 -0
- package/dist/inbox-admin.service.js +102 -0
- package/dist/inbox-admin.service.js.map +1 -0
- package/dist/inbox.module.d.ts +13 -0
- package/dist/inbox.module.js +59 -0
- package/dist/inbox.module.js.map +1 -0
- package/dist/inbox.service.d.ts +22 -0
- package/dist/inbox.service.js +74 -0
- package/dist/inbox.service.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations/tenancy.d.ts +25 -0
- package/dist/integrations/tenancy.js +56 -0
- package/dist/integrations/tenancy.js.map +1 -0
- package/dist/internal/handler-context.d.ts +3 -0
- package/dist/internal/handler-context.js +39 -0
- package/dist/internal/handler-context.js.map +1 -0
- package/dist/internal/options.d.ts +5 -0
- package/dist/internal/options.js +86 -0
- package/dist/internal/options.js.map +1 -0
- package/dist/internal/reliability-core/abort.d.ts +2 -0
- package/dist/internal/reliability-core/abort.js +21 -0
- package/dist/internal/reliability-core/abort.js.map +1 -0
- package/dist/internal/reliability-core/index.d.ts +3 -0
- package/dist/internal/reliability-core/index.js +14 -0
- package/dist/internal/reliability-core/index.js.map +1 -0
- package/dist/internal/reliability-core/json.d.ts +13 -0
- package/dist/internal/reliability-core/json.js +125 -0
- package/dist/internal/reliability-core/json.js.map +1 -0
- package/dist/internal/reliability-core/retry.d.ts +8 -0
- package/dist/internal/reliability-core/retry.js +26 -0
- package/dist/internal/reliability-core/retry.js.map +1 -0
- package/dist/internal/validation.d.ts +6 -0
- package/dist/internal/validation.js +123 -0
- package/dist/internal/validation.js.map +1 -0
- package/dist/storage/prisma/prisma-inbox.storage.d.ts +30 -0
- package/dist/storage/prisma/prisma-inbox.storage.js +369 -0
- package/dist/storage/prisma/prisma-inbox.storage.js.map +1 -0
- package/dist/worker/inbox-worker.d.ts +31 -0
- package/dist/worker/inbox-worker.js +343 -0
- package/dist/worker/inbox-worker.js.map +1 -0
- package/docs/adr/0001-durable-inbox.md +40 -0
- package/docs/benchmarks/2026-10-27-local.md +31 -0
- package/docs/benchmarks/2026-10-28-preview1-local.md +87 -0
- package/docs/benchmarks/2026-10-28-preview1-run1.json +120 -0
- package/docs/benchmarks/2026-10-28-preview1-run2.json +120 -0
- package/docs/benchmarks/2026-10-28-preview1-run3.json +120 -0
- package/docs/implementation-roadmap.md +127 -0
- package/docs/package-plan.md +250 -0
- package/docs/releasing.md +83 -0
- package/docs/usage.md +266 -0
- package/docs/validation.md +139 -0
- package/examples/README.md +124 -0
- package/examples/compose.yaml +26 -0
- package/examples/package.json +42 -0
- package/examples/prisma/schema.prisma +29 -0
- package/examples/prisma.config.ts +10 -0
- package/examples/scripts/prepare-db.cjs +45 -0
- package/examples/scripts/smoke.cjs +37 -0
- package/examples/scripts/with-brokers.cjs +249 -0
- package/examples/scripts/with-rabbitmq.cjs +131 -0
- package/examples/sql/application.sql +14 -0
- package/examples/sql/tenancy.sql +26 -0
- package/examples/src/broker-common.ts +79 -0
- package/examples/src/bullmq-ingress/main.ts +165 -0
- package/examples/src/common.ts +112 -0
- package/examples/src/idempotency-ingress/main.ts +211 -0
- package/examples/src/kafka-ingress/main.ts +209 -0
- package/examples/src/outbox-to-inbox/main.ts +198 -0
- package/examples/src/rabbitmq-ingress/main.ts +203 -0
- package/examples/src/sqs-ingress/main.ts +273 -0
- package/examples/src/tenancy/main.ts +210 -0
- package/examples/src/webhook-ingress/main.ts +172 -0
- package/examples/tsconfig.json +16 -0
- package/package.json +90 -4
- package/src/sql/create-inbox-table.sql +100 -0
- package/src/sql/migrate-v1-to-v2.sql +53 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# GitHub Release에서 npm 배포
|
|
2
|
+
|
|
3
|
+
`release.yml`은 GitHub Release의 **Publish release** 이벤트로 실행된다. `package.json` 버전이 `0.1.0`이면 태그는 `v0.1.0`, GitHub의 **Set as a pre-release**는 꺼져 있어야 한다. `-preview.1` 같은 prerelease 버전을 사용할 때만 이 옵션을 켠다. 태그만 push하거나 초안을 저장하면 배포하지 않는다.
|
|
4
|
+
|
|
5
|
+
인증 방식은 다른 nestarc 패키지와 동일한 **npm Trusted Publishing(GitHub OIDC)** 이다. `NPM_TOKEN`이나 `NODE_AUTH_TOKEN` secret을 추가하지 않는다. 배포 job만 `npm` GitHub Environment와 `id-token: write`를 사용하며, 빌드와 테스트 job은 저장소 읽기 권한만 가진다.
|
|
6
|
+
|
|
7
|
+
## 최초 연결
|
|
8
|
+
|
|
9
|
+
1. 이 구현과 lockfile을 `nestarc/inbox`의 `main`에 반영한다.
|
|
10
|
+
2. GitHub 저장소 **Settings → Environments → npm**을 준비한다. Deployment branches and tags는 Selected branches and tags로 설정하고 tag 패턴 `v*.*.*`를 허용한다. 팀 정책에 따라 reviewer를 추가할 수 있다.
|
|
11
|
+
3. npm에 `@nestarc/inbox`가 아직 없으면 권한 있는 관리자가 최초 버전을 등록한다. npm의 Trusted Publisher 설정은 이미 존재하는 패키지가 필요하다. 최초 등록에도 아래 검증한 tarball을 사용하고, `0.1.0`은 `--tag latest`, preview는 `--tag next`로 게시한다. 이 작업은 계정 로그인·2FA가 필요한 일회성 절차이며 워크플로우에 장기 토큰을 넣는 대체 경로는 없다.
|
|
12
|
+
4. npm 패키지 **Settings → Trusted Publisher → GitHub Actions**를 아래 값으로 연결한다. 현재 npm 설정에서 **Allow this trusted publisher to publish packages**를 활성화한다. stage만 허용된 설정으로는 이 워크플로우의 직접 publish가 성공하지 않는다.
|
|
13
|
+
|
|
14
|
+
| 항목 | 값 |
|
|
15
|
+
| -------------------- | ------------- |
|
|
16
|
+
| Organization or user | `nestarc` |
|
|
17
|
+
| Repository | `inbox` |
|
|
18
|
+
| Workflow filename | `release.yml` |
|
|
19
|
+
| Environment | `npm` |
|
|
20
|
+
|
|
21
|
+
최신 npm CLI에서 동일한 설정을 만드는 명령은 다음과 같다. 패키지 쓰기 권한과 2FA가 있는 관리자 계정에서 실행한다.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm trust github @nestarc/inbox \
|
|
25
|
+
--file release.yml \
|
|
26
|
+
--repo nestarc/inbox \
|
|
27
|
+
--environment npm \
|
|
28
|
+
--allow-publish
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
공식 [Trusted Publishers 안내](https://docs.npmjs.com/trusted-publishers/)와 [npm trust 명령](https://docs.npmjs.com/cli/v11/commands/npm-trust/)을 참고한다. 새 연결은 npm이 정한 활성화 기한 안에 첫 게시를 성공시켜야 한다. 워크플로우는 GitHub hosted runner, Node 24, npm 11.5.1 이상을 사용하고 npm 버전이 부족하면 실패한다. 로컬 검증이나 dry-run으로 원격 OIDC 연결까지 확인할 수는 없다.
|
|
32
|
+
|
|
33
|
+
최초 수동 등록 이후에는 버전을 올려 첫 OIDC 배포를 확인한다. 이미 게시된 동일 버전은 무결성 확인 후 건너뛰므로 Trusted Publisher의 실제 인증을 검증하지 않는다.
|
|
34
|
+
|
|
35
|
+
## 릴리즈 절차
|
|
36
|
+
|
|
37
|
+
1. 버전과 변경 기록을 갱신하고 `package.json`과 `package-lock.json`을 함께 커밋한다. 해당 커밋을 `main`에 병합한다.
|
|
38
|
+
2. 필요하면 Actions의 **Release → Run workflow**로 검증한다. 이 경로는 전체 검증과 `npm publish --dry-run`까지만 실행하며 게시 권한을 요청하지 않는다.
|
|
39
|
+
3. `main`에 포함된 커밋에 `v<package.json 버전>` 태그로 GitHub Release를 게시한다. 버전에 `-preview.1`, `-rc.1` 같은 prerelease 식별자가 있으면 GitHub prerelease 체크도 켠다.
|
|
40
|
+
4. Release 실행에서 `publish` job 성공과 npm 버전·integrity 확인 결과를 확인한다.
|
|
41
|
+
|
|
42
|
+
| 버전 예 | GitHub prerelease | npm dist-tag |
|
|
43
|
+
| ----------------- | ----------------- | ------------ |
|
|
44
|
+
| `0.1.0-preview.1` | 켬 | `next` |
|
|
45
|
+
| `0.1.0-rc.1` | 켬 | `next` |
|
|
46
|
+
| `0.1.0` | 끔 | `latest` |
|
|
47
|
+
|
|
48
|
+
현재 `publishConfig.tag`는 `latest`이며, 배포 명령은 버전에 따라 `latest` 또는 `next`를 명시해 기본 설정을 덮어쓴다. 태그·버전·prerelease 표시가 불일치하거나 태그 커밋이 `main` 이력에 없으면 테스트와 게시 전에 거절한다. 빌드 메타데이터가 있는 버전(`1.0.0+build`)은 받지 않는다.
|
|
49
|
+
|
|
50
|
+
## 검증과 배포물
|
|
51
|
+
|
|
52
|
+
CI와 Release는 `release-validation.yml`을 공유한다. Node 22에서 한 번 빌드한 후 `package.tgz`와 `metadata.json`을 보관한다. metadata는 저장소·commit·ref·버전, 파일 목록, SHA-256과 npm SHA-512 integrity를 기록한다. SQL 설치/upgrade 파일, JavaScript 및 타입 진입점이 필요하며 환경 파일·의존성 디렉터리·생성 client·private workspace 패키지와 archive link는 거절한다.
|
|
53
|
+
|
|
54
|
+
Node 22/24 × PostgreSQL 16/18의 네 job이 같은 tarball을 내려받아 실제 DB 통합/복구 테스트와 독립 consumer 설치를 실행한다. Node 24/PostgreSQL 16에서는 tarball 내부 예제 8종을 실제 임시 브로커와 함께 검사한다. 각 consumer는 복사본을 읽기 전용으로 사용하며 실행 전후 원본·복사본·metadata 변경을 검사한다.
|
|
55
|
+
|
|
56
|
+
네 조합이 모두 성공해야 배포 job이 실행된다. 배포 job에서는 의존성 설치·빌드·재패킹을 하지 않고 해당 tarball을 `--ignore-scripts --provenance`로 게시한다. 게시 후 레지스트리의 integrity가 검증한 tarball과 같은지 확인한다.
|
|
57
|
+
|
|
58
|
+
같은 버전이 이미 존재하면 bytes가 같은 경우만 게시를 건너뛴다. 다른 bytes이거나 레지스트리 조회가 404 이외의 오류이면 실패한다. 기존 버전을 덮어쓰지 말고 원인을 확인한 다음 새 버전을 사용한다. 같은 버전의 재실행은 기존 dist-tag를 이동시키지 않는다.
|
|
59
|
+
|
|
60
|
+
## 로컬 재현
|
|
61
|
+
|
|
62
|
+
Node 22.14 이상과 Docker가 필요하다. 임시 디렉터리는 저장소 밖에 둔다.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npm ci
|
|
66
|
+
npm run format:check
|
|
67
|
+
npm run test:release
|
|
68
|
+
npm run build
|
|
69
|
+
npm run typecheck
|
|
70
|
+
npm test
|
|
71
|
+
npm run test:integration
|
|
72
|
+
|
|
73
|
+
INBOX_CANDIDATE_DIR=$(mktemp -d)
|
|
74
|
+
node scripts/release-artifact.mjs pack "$INBOX_CANDIDATE_DIR"
|
|
75
|
+
export INBOX_RELEASE_TARBALL="$INBOX_CANDIDATE_DIR/package.tgz"
|
|
76
|
+
export INBOX_RELEASE_METADATA="$INBOX_CANDIDATE_DIR/metadata.json"
|
|
77
|
+
npm run test:package
|
|
78
|
+
npm run test:examples
|
|
79
|
+
node scripts/release-artifact.mjs verify "$INBOX_RELEASE_TARBALL" "$INBOX_RELEASE_METADATA"
|
|
80
|
+
npm publish "$INBOX_RELEASE_TARBALL" --ignore-scripts --access public --tag latest --dry-run
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`INBOX_RELEASE_TARBALL`과 `INBOX_RELEASE_METADATA`는 둘 다 절대 경로로 지정한다. 두 변수를 생략한 일반 `test:package` / `test:examples` 실행은 기존처럼 로컬에서 빌드와 pack을 수행한다.
|
package/docs/usage.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# @nestarc/inbox usage
|
|
2
|
+
|
|
3
|
+
This document describes the 0.1.0 API. Inbox durably receives messages and processes them with Prisma and PostgreSQL. The application owns authentication, broker acknowledgments, its Prisma client, and migration deployment. APIs and schema may change before 1.0; review the [changelog](../CHANGELOG.md) before upgrading.
|
|
4
|
+
|
|
5
|
+
Install with `npm install @nestarc/inbox@0.1.0`. To evaluate a source build, run `npm ci` and `npm pack` in the repository, then install the resulting `nestarc-inbox-0.1.0.tgz` in your application.
|
|
6
|
+
|
|
7
|
+
## Initialize the schema and Prisma client
|
|
8
|
+
|
|
9
|
+
Apply the SQL exported as `@nestarc/inbox/sql/create-inbox-table.sql` through your application's migration process before starting Inbox. Runtime initialization checks the schema; it does not create or upgrade tables. Keep the inbox SQL tables in your migration history and do not let Prisma schema synchronization remove unmanaged tables.
|
|
10
|
+
|
|
11
|
+
Version 0.1.0 requires schema version 2. If you used schema v1 in a local development build, stop those processes, apply the exported `@nestarc/inbox/sql/migrate-v1-to-v2.sql`, then start 0.1.0 workers. The transactional migration preserves receipt identity, payloads, state, attempts, and tokens and may be applied again safely. It takes a table lock, so schedule the upgrade appropriately. Fresh installs use the current creation SQL. Old workers must not be restarted against v2. Local Preview.1 builds already use schema v2; the 0.1.0 promotion adds no schema change.
|
|
12
|
+
|
|
13
|
+
Prisma 7 requires a driver adapter. Construct one application-owned client and pass it to Inbox:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
17
|
+
import { PrismaClient } from "./generated/prisma/client";
|
|
18
|
+
|
|
19
|
+
const prisma = new PrismaClient({
|
|
20
|
+
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use the API for your installed Prisma major. The [runnable examples](../examples/README.md) pin Prisma 7 and support the npm package or a local tarball; automated package checks use a tarball built from the checkout.
|
|
25
|
+
|
|
26
|
+
## Register a consumer
|
|
27
|
+
|
|
28
|
+
Register stable logical consumer names and explicitly enable the worker:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { Module } from "@nestjs/common";
|
|
32
|
+
import { InboxModule, InboxPermanentError } from "@nestarc/inbox";
|
|
33
|
+
import type { Prisma } from "./generated/prisma/client";
|
|
34
|
+
import type { InboxHandler } from "@nestarc/inbox";
|
|
35
|
+
|
|
36
|
+
const handler: InboxHandler<{ orderId: string }, Prisma.TransactionClient> = {
|
|
37
|
+
consumer: "orders.projection.v1",
|
|
38
|
+
eventType: "order.created",
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
validate(payload) {
|
|
41
|
+
return (
|
|
42
|
+
!!payload &&
|
|
43
|
+
typeof payload === "object" &&
|
|
44
|
+
!Array.isArray(payload) &&
|
|
45
|
+
typeof payload.orderId === "string"
|
|
46
|
+
);
|
|
47
|
+
},
|
|
48
|
+
async handle(payload, context) {
|
|
49
|
+
if (context.scope.kind !== "tenant") throw new InboxPermanentError();
|
|
50
|
+
await context.tx.orderProjection.create({
|
|
51
|
+
data: { tenantId: context.scope.id, orderId: payload.orderId },
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
@Module({
|
|
57
|
+
imports: [
|
|
58
|
+
InboxModule.forRoot({
|
|
59
|
+
prisma,
|
|
60
|
+
handlers: [handler],
|
|
61
|
+
worker: { enabled: true },
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
export class AppModule {}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The application defines its Prisma models and supplies `prisma` above. For dependency injection, use `InboxModule.forRootAsync({ imports, inject, useFactory })`; the factory returns the same options. Create handlers inside the factory when they depend on other Nest services.
|
|
69
|
+
|
|
70
|
+
Worker processing is disabled unless `worker.enabled` is true. An ingress-only process supplies `consumers: ['orders.projection.v1']` and no handlers. A worker process registers handlers and may restrict processing through `worker.consumers`. Claiming, lease recovery, and exhausted-attempt handling stay inside that list. A missing handler for a configured worker consumer or a duplicate handler registration fails startup.
|
|
71
|
+
|
|
72
|
+
Removing every worker for a consumer leaves its backlog pending. Monitor backlog age before removing a deployment. Unsupported event types or schema versions for a registered consumer are retained as DEAD; they are never silently acknowledged as completed business work.
|
|
73
|
+
|
|
74
|
+
## Worker configuration
|
|
75
|
+
|
|
76
|
+
| Option | Default | Meaning |
|
|
77
|
+
| ----------------------------- | ------------------------ | ----------------------------------------------------------------------- |
|
|
78
|
+
| `worker.enabled` | `false` | Start polling during application initialization |
|
|
79
|
+
| `worker.consumers` | Registered handler names | The worker's explicit ownership list |
|
|
80
|
+
| `worker.concurrency` | `4` | Maximum claimed, active records; upper bound 100 |
|
|
81
|
+
| `worker.pollIntervalMs` | `1000` | Delay between polling opportunities |
|
|
82
|
+
| `worker.leaseMs` | `30000` | Database lease duration |
|
|
83
|
+
| `worker.heartbeatIntervalMs` | `5000` | Renewal interval; must be less than half the lease duration |
|
|
84
|
+
| `worker.transactionTimeoutMs` | `10000` | Maximum handler transaction duration |
|
|
85
|
+
| `worker.transactionMaxWaitMs` | `5000` | Maximum wait to acquire a Prisma transaction connection |
|
|
86
|
+
| `worker.lockTimeoutMs` | `1000` | Database lock-wait bound for the handler transaction |
|
|
87
|
+
| `worker.shutdownTimeoutMs` | `15000` | Bounded drain interval on shutdown |
|
|
88
|
+
| `worker.maxAttempts` | `8` | Total claims per retry cycle, including the initial attempt and crashes |
|
|
89
|
+
| `worker.retryBaseMs` | `1000` | Base for exponential retry delay |
|
|
90
|
+
| `worker.retryMaxMs` | `300000` | Maximum retry delay before jitter |
|
|
91
|
+
| `maxPayloadBytes` | `262144` | Accepted JSON payload size in UTF-8 bytes |
|
|
92
|
+
| `maxPendingMessages` | Unset | Optional approximate backlog admission bound |
|
|
93
|
+
|
|
94
|
+
Keep enough pool connections available for admission, heartbeat, and state changes in addition to handler transactions. Larger concurrency does not automatically improve throughput. Leave external network work out of the transaction and tune limits with measurements from your own database.
|
|
95
|
+
|
|
96
|
+
For explicit execution without a continuously polling process, `InboxService.initialize()` checks the schema, `runOnce()` processes a bounded batch, and `close()` stops the worker. A Nest module invokes initialization and shutdown automatically. `close()` does not disconnect the application's Prisma client.
|
|
97
|
+
|
|
98
|
+
## Accept a message and acknowledge it
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const receipt = await inbox.accept({
|
|
102
|
+
scope: { kind: "tenant", id: verifiedTenantId },
|
|
103
|
+
source: "orders-service",
|
|
104
|
+
consumer: "orders.projection.v1",
|
|
105
|
+
messageId: event.id,
|
|
106
|
+
eventType: "order.created",
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
payload: { orderId: event.orderId },
|
|
109
|
+
});
|
|
110
|
+
channel.ack(message);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`accept()` resolves only after its own transaction commits. It returns `{ kind: 'accepted' | 'duplicate', id, status }`. Both kinds permit transport acknowledgment. A duplicate can refer to a PENDING, PROCESSING, RETRYABLE, COMPLETED, or DEAD record; it never resets processing state or retry budgets. DEAD records require operator intervention.
|
|
114
|
+
|
|
115
|
+
A storage exception or uncertain acceptance commit result must not produce a success ACK. Let the transport redeliver the same stable identity. Authentication failures, invalid input, and identity conflicts require an explicit rejection or transport dead-letter policy rather than a hot retry loop.
|
|
116
|
+
|
|
117
|
+
Inbox does not acknowledge messages, return HTTP responses, or connect to brokers itself. [Runnable RabbitMQ and webhook examples](../examples/README.md) show those application boundaries.
|
|
118
|
+
|
|
119
|
+
## Identity and payload
|
|
120
|
+
|
|
121
|
+
The unique identity combines scope, source, consumer, and message ID. Scope is either `{ kind: 'tenant', id }` or `{ kind: 'global' }`. Tenant IDs are application-authenticated values; scope selection alone does not implement PostgreSQL RLS.
|
|
122
|
+
|
|
123
|
+
Use a producer's stable event ID. Do not use a delivery tag, attempt count, or a new random ID for every redelivery. Renaming a consumer creates a different logical effect and permits processing again.
|
|
124
|
+
|
|
125
|
+
Fingerprinting covers event type, schema version, and canonical JSON payload. Object key order does not change identity content; array order does. Trace headers, correlation ID, and causation ID are metadata and do not participate. Keep values that affect business decisions inside the payload.
|
|
126
|
+
|
|
127
|
+
The same identity with different fingerprint throws `InboxConflictError` with code `IDENTITY_CONFLICT` and leaves the existing record unchanged. JSON must contain only supported JSON values. Non-finite numbers, undefined, arbitrary class instances, circular values, excessive depth, and values exceeding configured limits are rejected. No value is silently truncated to fit.
|
|
128
|
+
|
|
129
|
+
## Handler transaction and retries
|
|
130
|
+
|
|
131
|
+
Use only `context.tx` for handler writes. Inbox adds the fenced completion write and commits the whole transaction. Let transaction errors escape. Calling another Prisma client, nesting a separate transaction, or making an external API call breaks this atomic boundary.
|
|
132
|
+
|
|
133
|
+
An outbox event can join the same transaction:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
await outbox.emit(context.tx, new OrderProjectedEvent(payload.orderId), {
|
|
137
|
+
tenantId: context.scope.kind === "tenant" ? context.scope.id : undefined,
|
|
138
|
+
...(context.scope.kind === "global"
|
|
139
|
+
? { tenantScope: "global" as const }
|
|
140
|
+
: {}),
|
|
141
|
+
causationId: context.messageId,
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Handler invocations can repeat. Successfully committed same-database effects are deduplicated while the completion receipt remains. Different event IDs for the same business action still require an application unique constraint. Ordering is not guaranteed.
|
|
146
|
+
|
|
147
|
+
Claims consume an attempt before handler execution, so crashes consume retry budget too. A normal error schedules a durable retry using capped exponential backoff and full jitter. `InboxPermanentError`, invalid handler payloads, unsupported schemas, and exhausted attempts lead to DEAD. Error details are redacted before persistence and observation.
|
|
148
|
+
|
|
149
|
+
Heartbeats extend only an owned, unexpired lease. Stale completion rolls back handler writes. `context.signal` is an advisory cancellation signal; handlers should stop cooperative work when it aborts. Database and callback timeouts bound the transaction even when a handler ignores that signal. No cancellation mechanism can undo an external API call.
|
|
150
|
+
|
|
151
|
+
When a completion commit response is lost, Inbox reads the current record before deciding on a failure transition. If that read is unavailable, it leaves recovery to the durable lease rather than overwriting a potentially completed record.
|
|
152
|
+
|
|
153
|
+
## Configuration
|
|
154
|
+
|
|
155
|
+
The default settings favor short database work. All durations are milliseconds.
|
|
156
|
+
|
|
157
|
+
| Option | Default |
|
|
158
|
+
| ------------------------------------------------------ | ----------------------------------- |
|
|
159
|
+
| `worker.enabled` | `false` |
|
|
160
|
+
| `worker.concurrency` / `pollIntervalMs` | `4` / `1000` |
|
|
161
|
+
| `worker.leaseMs` / `heartbeatIntervalMs` | `30000` / `5000` |
|
|
162
|
+
| `worker.transactionTimeoutMs` / `transactionMaxWaitMs` | `10000` / `5000` |
|
|
163
|
+
| `worker.lockTimeoutMs` / `shutdownTimeoutMs` | `1000` / `15000` |
|
|
164
|
+
| `worker.maxAttempts` | `8`, including the initial attempt |
|
|
165
|
+
| `worker.retryBaseMs` / `retryMaxMs` | `1000` / `300000`, with full jitter |
|
|
166
|
+
| `maxPayloadBytes` | `262144` (256 KiB) |
|
|
167
|
+
| `maxPendingMessages` | No configured admission limit |
|
|
168
|
+
|
|
169
|
+
Handler deadlines bound heartbeats. Failure reconciliation has a separate deadline of at most five seconds so an unavailable status query cannot hold a worker slot forever. Configure the application's Prisma driver pool with enough connections for business transactions and control queries; Inbox does not create a second pool.
|
|
170
|
+
|
|
171
|
+
## Administration
|
|
172
|
+
|
|
173
|
+
Bind the tenant scope before reading or changing records:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const tenant = admin.forTenant(authenticatedTenantId);
|
|
177
|
+
const page = await tenant.list({ status: "DEAD", limit: 50 });
|
|
178
|
+
const item = await tenant.get(recordId);
|
|
179
|
+
if (item?.status === "DEAD") {
|
|
180
|
+
await tenant.requeue({
|
|
181
|
+
id: item.id,
|
|
182
|
+
expectedVersion: item.rowVersion,
|
|
183
|
+
reason: "Handler corrected and deployed",
|
|
184
|
+
actor: operatorId,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const stats = await tenant.stats();
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The application authorizes the operator and derives the tenant scope. `forGlobal()` manages intentionally global records, not all tenants. `InboxPrivilegedAdminService` is an explicitly enabled, cross-tenant operator surface; configure `privilegedAdmin: true` only in an authorized control-plane application and never expose it directly as a tenant-facing controller. For `forRootAsync`, that opt-in belongs at the top level beside `imports`, `inject`, and `useFactory`, not in the returned factory options.
|
|
191
|
+
|
|
192
|
+
Requeue only accepts DEAD with the observed version. Concurrent or repeated stale operations fail with `InboxStateConflictError`. It keeps the original payload, identity, claim token, lifetime attempts, and requeue history counters, and resets the current retry-cycle budget. COMPLETED receipts cannot be reset through requeue; use the separate explicit replay API below.
|
|
193
|
+
|
|
194
|
+
### Explicit replay of a completed message
|
|
195
|
+
|
|
196
|
+
Replay deliberately permits another logical processing operation. It creates a new PENDING receipt and leaves the original completion and deduplication identity unchanged. Choose a handler whose business semantics allow reprocessing, for example a projection rebuild consumer. Business uniqueness and external side effects still need their own contracts.
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const original = await admin.forTenant(authenticatedTenantId).get(originalId);
|
|
200
|
+
if (original?.status === "COMPLETED") {
|
|
201
|
+
const replay = await admin.forTenant(authenticatedTenantId).replay({
|
|
202
|
+
id: original.id,
|
|
203
|
+
expectedVersion: original.rowVersion,
|
|
204
|
+
requestId: persistedOperatorRequestUuid,
|
|
205
|
+
targetConsumer: "orders.projection.rebuild.v1",
|
|
206
|
+
actor: operatorId,
|
|
207
|
+
reason: "Rebuild the projection after deploying the corrected handler",
|
|
208
|
+
});
|
|
209
|
+
// replay.kind is accepted or duplicate; replay.record is the new receipt.
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Generate and persist one UUID per intended operator request, then reuse it unchanged after timeouts. The UUID is unique within the original tenant/global scope. Reusing it with different arguments throws `InboxReplayConflictError`. Retrying the same request returns its existing receipt even after payload cleanup. A new request needs a retained COMPLETED payload and the observed version; otherwise it fails. Payload mutation is not supported. The destination consumer must have a deployed matching handler; replay does not create one. `record.replay` and `context.replay` expose original inbox ID, source version, request ID, actor, reason, and request time. Payload cleanup preserves this lineage.
|
|
214
|
+
|
|
215
|
+
### Tenant context in the handler transaction
|
|
216
|
+
|
|
217
|
+
Install the optional `@nestarc/tenancy` peer and inject its context and service:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
InboxModule.forRootAsync({
|
|
221
|
+
imports: [TenancyModule],
|
|
222
|
+
inject: [PrismaService, TenancyService],
|
|
223
|
+
useFactory: (prisma, tenancy) => ({
|
|
224
|
+
prisma,
|
|
225
|
+
handlers,
|
|
226
|
+
handlerContext: createInboxTenancyContext({
|
|
227
|
+
context: new TenancyContext(),
|
|
228
|
+
tenancy,
|
|
229
|
+
}),
|
|
230
|
+
worker: { enabled: true },
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Use the application's raw Prisma client, not the tenancy extension that opens transactions of its own. The adapter binds async tenant context and sets the service's canonical PostgreSQL tenant setting with transaction-local `set_config` before invoking the handler on the same `context.tx`. The setting clears on commit or rollback. The application authenticates the scope, installs policies using that setting, and runs with a non-owner, non-superuser, non-BYPASSRLS database role. Inbox control tables are worker-owned; tenant isolation policies apply to application data. Scope selection alone does not authorize access.
|
|
236
|
+
|
|
237
|
+
`TenancyModule` 0.16.2 exports `TenancyService`, not the `TenancyContext` provider. Its public `new TenancyContext()` shares the package's async-local storage with the injected service. Import the configured tenancy module and the application's Prisma provider module into the factory module; the runnable example shows the full wiring.
|
|
238
|
+
|
|
239
|
+
Global messages default to a permanent rejection. `global: "shared"` explicitly permits shared-table work with an empty tenant setting; it does not bypass RLS or expose tenant rows. Generic `handlerContext(context, next)` wrappers must invoke and await `next()` exactly once; skipping it, invoking it twice, or swallowing a handler failure cannot produce a successful completion. Canceled work cannot start the handler after delayed context setup.
|
|
240
|
+
|
|
241
|
+
The tested tenancy version is 0.16.2, whose Node engine range is stricter than Inbox's: use Node 22.23.4+ on the 22 branch or 24.22.1+ on the 24 branch for that optional integration.
|
|
242
|
+
|
|
243
|
+
List results contain `records` and `nextCursor`. Pass the cursor back with the same filters to continue. Pagination order does not imply event execution order. Statistics count retained records and report the oldest pending work, so large histories have a query cost.
|
|
244
|
+
|
|
245
|
+
## Retention and observability
|
|
246
|
+
|
|
247
|
+
Completed payload cleanup is explicit:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
await admin.forTenant(authenticatedTenantId).purgeCompletedPayload({
|
|
251
|
+
before: new Date("2026-01-01T00:00:00Z"),
|
|
252
|
+
limit: 100,
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
This removes eligible COMPLETED payloads while preserving identity and fingerprint. It does not delete PENDING, PROCESSING, RETRYABLE, or DEAD payloads. Retained receipts keep old redeliveries deduplicated. Manually deleting a receipt ends that guarantee; restoring an older database backup also restores an older deduplication history.
|
|
257
|
+
|
|
258
|
+
`observer(event)` receives bounded, redacted observations for accepted, duplicate, completed, retry, dead, lease-lost, and infrastructure-error outcomes. Observers are best-effort and must not be used as a durable audit trail. Avoid high-cardinality metrics labels and monitor backlog age, DEAD count, pool pressure, and processing delay. Unknown or inactive consumers can otherwise accumulate pending work without a running owner.
|
|
259
|
+
|
|
260
|
+
The optional `maxPendingMessages` admission check is approximate under concurrency; it is not a strict quota. Duplicate acceptance remains valid when that limit is reached. Database exhaustion or admission rejection must remain a failed acceptance, never a success ACK.
|
|
261
|
+
|
|
262
|
+
## Shutdown and compatibility
|
|
263
|
+
|
|
264
|
+
Enable Nest shutdown hooks for operating-system signals, and close the Nest application before disconnecting its Prisma pool. Inbox stops new claims, drains active work within its shutdown limit, and aborts timed-out work so leases can recover. The process manager's grace period should accommodate application cleanup.
|
|
265
|
+
|
|
266
|
+
Support declarations must match the executed compatibility suite. See repository validation results for tested Node, NestJS, Prisma, and PostgreSQL versions; the planning matrix is not itself evidence of support. Release migrations, actual SQL assets, and packed-consumer tests are part of that contract.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @nestarc/inbox MVP 검증 기록
|
|
2
|
+
|
|
3
|
+
## GitHub hosted CI 검증 — 2026년 10월 28일
|
|
4
|
+
|
|
5
|
+
커밋 `664b4fc`의 [CI 실행 40684612217](https://github.com/nestarc/inbox/actions/runs/40684612217)이 전체 성공했다. GitHub hosted Linux runner에서 Node 22/24 × PostgreSQL 16/18 네 조합의 단위·통합/복구·동일 tarball 소비자 검증을 통과했고, Node 24/PostgreSQL 16에서 브로커를 포함한 예제 8종도 통과했다. 빌드·타입·포맷·릴리즈 검증과 artifact 무결성 확인도 성공했다.
|
|
6
|
+
|
|
7
|
+
이 기록은 Preview.1 당시 커밋에 대한 실제 원격 결과다. 아래에서 hosted CI를 미확인으로 적은 문장은 이 실행 전 작성된 이력이며, npm 게시·OIDC 인증 성공과는 별개의 검증이다. 0.1.0은 이 런타임과 schema v2를 유지하고 버전 metadata와 문서를 갱신한 첫 공개 릴리즈다.
|
|
8
|
+
|
|
9
|
+
## README 온보딩과 성능 기록 검증 — 2026년 10월 28일
|
|
10
|
+
|
|
11
|
+
README의 Prisma model과 TypeScript 모듈 코드 블록을 수정 없이 추출해 별도 앱에서 검증했다. Node 24.22.1, NestJS 11.2.8, Prisma 7.10.1, TypeScript 5.9.3과 임시 PostgreSQL 16.15를 사용했다. `prisma-client` generator의 CommonJS client 생성과 strict TypeScript 컴파일을 통과했고, 실제 Nest application context에서 같은 ID를 두 번 수신해 `accepted` → `duplicate`, 동일 receipt, `COMPLETED`, 업무 projection 1건을 확인했다. 선택적 tenancy 패키지 없이 동작했고 앱·Prisma client·DB 연결을 순서대로 종료했다. 검증용 DB 컨테이너도 정리했다.
|
|
12
|
+
|
|
13
|
+
현재 Preview.1/schema v2로 benchmark를 세 번 연속 실행했다. 9개 시나리오 모두 업무 효과 수와 완료 상태 검증을 통과했고 수신·worker 오류는 0건이었다. 느렸던 세 번째 실행을 포함한 전체 범위와 원본 JSON은 [새 성능 기록](benchmarks/2026-10-28-preview1-local.md)에 보존했다. README의 수신 처리량과 p95는 이 기록을 반올림한 값이며 지속 처리량이나 SLA로 해석하지 않는다.
|
|
14
|
+
|
|
15
|
+
## 릴리즈 자동화 검증 — 2026년 10월 28일
|
|
16
|
+
|
|
17
|
+
GitHub Release 게시 시 npm OIDC로 배포하는 [워크플로우](../.github/workflows/release.yml)와 CI가 [공통 검증](../.github/workflows/release-validation.yml)을 사용하도록 구성했다. 관련 설정과 최초 연결 절차는 [배포 안내](releasing.md)에 있다.
|
|
18
|
+
|
|
19
|
+
| 검사 | 결과 |
|
|
20
|
+
| ---------------------------- | -------------------------------------------------------------------------------------- |
|
|
21
|
+
| actionlint 1.7.12 | 워크플로우 3개 오류 없음. 선택적 ShellCheck는 미설치 |
|
|
22
|
+
| 릴리즈 회귀 테스트 | Node 22.23.4 / 24.22.1에서 각각 41개 통과 |
|
|
23
|
+
| Linux / GNU tar 1.34 | 초기 전체 38개, 추가 ref 검증을 포함한 artifact suite 21개 통과 및 실제 후보 검증 통과 |
|
|
24
|
+
| 빌드·타입·포맷·단위 테스트 | 통과, 단위 테스트 85개 |
|
|
25
|
+
| Node 24.22.1 / PostgreSQL 18 | 통합·프로세스 종료 복구 38개 통과 |
|
|
26
|
+
| 동일 후보의 독립 소비자 | Node 22 / PostgreSQL 16 및 Node 24 / PostgreSQL 18 통과 |
|
|
27
|
+
| 동일 후보의 예제 8종 | Node 24 / PostgreSQL 16 및 임시 브로커에서 모두 통과 |
|
|
28
|
+
| npm publish dry-run | `next`·public 설정 통과, 실제 게시 없음 |
|
|
29
|
+
|
|
30
|
+
한 번 생성한 82개 파일·93,668바이트 tarball을 consumer와 예제에 전달했고, 실행 전후 metadata와 SHA-512 integrity 일치를 확인했다. 릴리즈 테스트는 태그/버전/이벤트/commit 불일치, main 이력 검사 실패, artifact 변조·금지 경로, 레지스트리 조회 오류와 기존 버전 충돌을 검사한다. Unicode·`+`·`@`가 있는 정상 브랜치명도 CI에서 허용한다.
|
|
31
|
+
|
|
32
|
+
GitHub의 `nestarc/inbox`에 `npm` Environment와 tag `v*.*.*` 배포 제한을 생성하고 API로 확인했다. npm 최초 등록·Trusted Publisher 연결·실제 OIDC publish는 아직 수행하지 않았다. GitHub hosted runner의 전체 matrix 성공과 npm provenance 발급을 로컬 검사 결과만으로 주장하지 않는다. 검증에 사용한 임시 DB·브로커 컨테이너는 정리했다.
|
|
33
|
+
|
|
34
|
+
## Preview.1 추가 검증 — 2026년 10월 28일
|
|
35
|
+
|
|
36
|
+
`0.1.0-preview.1`, schema v2를 Node 22.23.4, NestJS 11.2.8, Prisma 7.10.1에서 검증했다. 이번 실행 결과는 다음과 같다.
|
|
37
|
+
|
|
38
|
+
| 검사 | 결과 |
|
|
39
|
+
| ------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
40
|
+
| 빌드·TypeScript 검사·포맷 검사 | 통과 |
|
|
41
|
+
| 단위 테스트 | 7개 파일, 85개 통과 |
|
|
42
|
+
| PostgreSQL 16 통합·프로세스 종료 복구 | 4개 파일, 38개 통과 |
|
|
43
|
+
| PostgreSQL 18 통합·프로세스 종료 복구 | 4개 파일, 38개 통과 |
|
|
44
|
+
| 독립 tarball 소비자 | CJS/ESM, public 타입, SQL export, replay, 선택적 tenancy 미설치 상태 모두 통과 |
|
|
45
|
+
| tarball 내부 예제 8종 | outbox, webhook, tenancy, idempotency, RabbitMQ, Kafka, SQS 호환, BullMQ 모두 통과 |
|
|
46
|
+
|
|
47
|
+
추가한 [replay 테스트](../test/integration/replay.test.ts)는 동시 요청의 단일 receipt 생성, 원본 완료·중복 방지 보존, 별도 업무 효과 커밋, scope/version/상태·내용 충돌, payload 정리 후 요청 재시도, 용량 제한, v1→v2 migration과 스키마 제약을 검증한다. [tenancy 테스트](../test/integration/tenancy.test.ts)는 실제 `@nestarc/tenancy` 0.16.2 및 비소유자·비슈퍼유저·NOBYPASSRLS 역할로 tenant별 접근, 교차 tenant 쓰기 롤백, commit/rollback 후 pooled connection의 GUC 정리, global 기본 거절과 명시적 공유 경로를 검증한다. [handler context 테스트](../test/unit/handler-context.test.ts)는 next 생략·중복·오류 삼킴과 취소 이후 늦은 실행을 차단하는지 검사한다.
|
|
48
|
+
|
|
49
|
+
브로커 검증에는 RabbitMQ 4, Redpanda v26.2.6, Redis 7.4.2, ElasticMQ 1.7.1의 임시 Docker 컨테이너를 사용했다. 새 Kafka·SQS 호환·BullMQ 예제 각각에서 중복 전송 두 건이 업무 결과 한 건으로 수렴했고, 수신 전 일시 오류 재시도 한 번, handler 재시도 한 번, 잘못된 입력 격리를 확인했다. Kafka는 Redpanda의 Kafka protocol, SQS는 ElasticMQ 에뮬레이터에서 실행했으며 Apache Kafka의 모든 배포 조합이나 실제 AWS SQS 운영 환경까지 검증했다고 표현하지 않는다.
|
|
50
|
+
|
|
51
|
+
idempotency 예제는 실제 PostgreSQL response-cache 만료 이후에도 Inbox의 중복 방지와 내용 충돌 검사가 유지됨을 확인했다. 공통 JSON/hash·retry·취소 로직은 private source workspace로 분리했고 v1 fingerprint 고정값을 검증했다. 다른 저장소의 패키지를 이 workspace에 연결하거나 각 저장소의 lease SQL을 통합한 것은 아니다.
|
|
52
|
+
|
|
53
|
+
패키지 검증은 모든 archive 경로와 설치 결과를 재귀 검사한다. examples의 node_modules·dist·생성 Prisma client, test 디렉터리와 환경 파일을 제외했다. 기능 검증한 tarball은 약 90KB, 81개 파일이며 문서 변경에 따라 크기는 조금 달라질 수 있다. 새 스키마는 기존 업무 DB에 자동 적용되지 않으며, v1 사용자는 worker를 중지하고 명시적 upgrade SQL을 적용해야 한다.
|
|
54
|
+
|
|
55
|
+
아래 기록은 Preview.0 당시의 검증 이력이다. 실제 failover·장기 부하·외부 서비스 도입 등 운영 검증의 남은 항목은 여전히 유효하다.
|
|
56
|
+
|
|
57
|
+
## Preview.0 검증 이력
|
|
58
|
+
|
|
59
|
+
기준일: 2026년 10월 27일 · 대상: `0.1.0-preview.0`
|
|
60
|
+
|
|
61
|
+
수신 저장, 중복 제거, 같은 DB transaction의 업무 변경과 완료 처리, lease 복구, 운영 범위는 단위 테스트와 실제 PostgreSQL 통합 테스트로 검증한다. 아래 표는 [구현 로드맵](implementation-roadmap.md)의 AC01~AC25를 실행한 테스트에 연결한다. 테스트 통과는 표에 적힌 조건의 증거이며, 모든 운영 장애를 재현했다는 뜻은 아니다.
|
|
62
|
+
|
|
63
|
+
## 실행 환경과 재현 방법
|
|
64
|
+
|
|
65
|
+
NestJS 11.2.8과 Prisma 7.10.1을 사용하여 Node 22.23.4·24.11.1과 PostgreSQL 16·18의 네 조합에서 각각 실제 DB suite 25개를 통과했다. 단위 테스트 49개도 Node 22·24에서 통과했다. PostgreSQL 테스트는 Docker의 공식 이미지로 만든 임시 DB를 사용한다. 실행기는 기존 `DATABASE_URL`을 사용하지 않으며, 테스트는 fixture 표시와 loopback 주소를 검사한 뒤에만 테이블을 초기화한다. Docker나 DB를 사용할 수 없으면 실패하며 조용히 건너뛰지 않는다.
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm ci
|
|
69
|
+
npm run build
|
|
70
|
+
npm test
|
|
71
|
+
npm run test:integration
|
|
72
|
+
npm run typecheck
|
|
73
|
+
npm run test:package
|
|
74
|
+
npm run test:examples
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`test:integration`은 테스트용 Prisma client를 생성한다. PostgreSQL 18은 `INBOX_TEST_POSTGRES_IMAGE=postgres:18-alpine npm run test:integration`으로 같은 suite를 실행할 수 있다. 실행 가능한 테스트 설정과 실제 확인한 호환성 조합은 구별한다.
|
|
78
|
+
|
|
79
|
+
## 테스트 종류
|
|
80
|
+
|
|
81
|
+
| 표의 이름 | 실제 파일과 검증 방식 |
|
|
82
|
+
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
83
|
+
| PG | [postgres.test.ts](../test/integration/postgres.test.ts): 실제 PostgreSQL, 생성된 Prisma client, 독립 DB 연결의 경합 |
|
|
84
|
+
| Crash | [crash-recovery.test.ts](../test/failure/crash-recovery.test.ts): [자식 프로세스](../test/failure/crash-child.ts)를 실제 `SIGKILL`로 종료 |
|
|
85
|
+
| Worker | [worker.test.ts](../test/unit/worker.test.ts): 저장소 대역과 제어된 시간·응답으로 worker의 분기와 수명 검증 |
|
|
86
|
+
| Validation | [validation.test.ts](../test/unit/validation.test.ts): JSON 정규화, fingerprint, 입력 상한·metadata 계약 |
|
|
87
|
+
| Nest | [module.test.ts](../test/unit/module.test.ts): Nest DI·모듈 생명주기와 범위를 전달하는 관리 API, DB는 대역 |
|
|
88
|
+
| Package | [test-package.mjs](../scripts/test-package.mjs): tarball을 별도 앱에 설치하고 실제 Prisma·DB로 public API 실행 |
|
|
89
|
+
| Examples | [smoke.cjs](../examples/scripts/smoke.cjs): 설치한 패키지와 실제 outbox·HTTP를 사용하는 예제 |
|
|
90
|
+
|
|
91
|
+
## 수용 기준별 증거
|
|
92
|
+
|
|
93
|
+
각 행의 따옴표 안 이름은 해당 파일의 테스트 이름이다. PG의 후속 이벤트 테이블은 원자성을 검증하기 위한 SQL fixture이며, 실제 `@nestarc/outbox` 조합은 AC20의 예제로 별도 확인한다.
|
|
94
|
+
|
|
95
|
+
| 기준 | 실행한 테스트 또는 예제 | 확인한 범위와 남은 조건 |
|
|
96
|
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
97
|
+
| AC01 동시 중복 수신 | PG: `AC01 accepts 100 concurrent duplicates once and commits one set of effects` | 같은 메시지 100회 동시 수신, 여러 claim 연결에서 수신 기록과 성공 업무 효과가 각각 하나 |
|
|
98
|
+
| AC02 identity 분리 | PG: `AC02 isolates tenant, global, source, and logical consumer identities` | tenant·전역 scope·source·consumer가 다른 동일 message ID를 독립 처리 |
|
|
99
|
+
| AC03 내용 충돌 | PG: `AC03 preserves a %s record on identity/content conflict` | PENDING·DEAD·COMPLETED 각각에서 fingerprint 충돌 시 원본 불변 |
|
|
100
|
+
| AC04 업무 실패 롤백 | PG: `AC04 rolls back generated Prisma model writes and follow-on events with handler failure` | 생성된 Prisma 모델 변경, 후속 이벤트 쓰기, 완료의 원자성 및 retry 일정 저장 |
|
|
101
|
+
| AC05 수신 후 ACK 전 종료 | Crash: `AC05 survives SIGKILL after acceptance commit and before the transport ACK` | 실제 프로세스 종료 뒤 같은 identity 재수신이 duplicate로 수렴. 여기서 ACK 경계는 테스트 수신부의 제어 지점 |
|
|
102
|
+
| AC06 claim 후 종료 | Crash: `AC06 recovers a committed claim after SIGKILL and real lease expiry` | 실제 프로세스 종료와 실제 lease 만료 뒤 새 token·증가한 attempt로 복구 |
|
|
103
|
+
| AC07 이전 worker 복귀 | PG: `AC07 rolls back all stale-worker writes after a competing claim acquires a newer token` | 열린 업무 transaction 중 별도 연결로 lease를 만료시켜 재확보한 뒤 이전 transaction 전체 롤백. 만료 시점은 SQL로 주입 |
|
|
104
|
+
| AC08 완료 CAS와 만료 경합 | PG: `AC08 protects a completed CAS with the row lock until commit even after lease expiry` | 실제 완료 갱신 후 COMMIT을 지연시키고 lease가 지나도 다른 연결의 claim이 이중 완료하지 못함 |
|
|
105
|
+
| AC09 커밋 결과 불확실 | PG: `AC09 reconciles a lost commit response without turning completed work into a retry`; Worker: `leaves a claim for lease recovery if commit state cannot be read` | 실제 DB COMMIT 직후 호출자에게 오류를 주입한 경우와 조회 실패 대역을 검증. 실제 TCP 단절·DB failover는 미재현 |
|
|
106
|
+
| AC10 최대 시도 | PG: `AC10 counts abandoned claims against the attempt budget and keeps other consumers untouched`; Worker: `reports DEAD for an exhausted attempt budget and caps retry delay` | claim을 반복 포기해도 예산을 소비하고 DEAD로 전환. retry 지연 상한은 단위 테스트 |
|
|
107
|
+
| AC11 requeue 경합 | PG: `AC11 and AC25 allow one requeue and reject stale heartbeats and failures after it` | 동시 requeue 중 하나만 성공, 누적 attempt·token 유지, 회차 예산 초기화 |
|
|
108
|
+
| AC12 완료 재실행 금지 | PG: `AC12 rejects requeue of a completed record` | requeue는 COMPLETED를 초기화하지 않음. Preview.1의 별도 replay는 새 receipt를 생성 |
|
|
109
|
+
| AC13 등록과 schema 오류 | PG: `AC13 quarantines an unsupported owned schema while leaving foreign consumers pending`; Worker: `rejects ambiguous registrations or a missing owned handler before polling` | 담당 consumer의 미지원 schema는 DEAD, 다른 consumer는 보존. 중복·누락 handler는 시작 전 거절 |
|
|
110
|
+
| AC14 tenant 관리 범위 | PG: `AC14 scopes reads, requeues, pagination, and stats to the selected tenant`; Nest: `captures the tenant scope in admin and validates requeue input` | 지정 범위 밖 조회·requeue 차단, 목록과 통계 분리. 앱 업무 테이블의 RLS 보장은 별도 |
|
|
111
|
+
| AC15 완료 payload 정리 | PG: `AC15 and AC16 purge only completed payloads while preserving dedupe and every recoverable payload` | payload를 지운 뒤에도 identity·fingerprint로 duplicate 판정 |
|
|
112
|
+
| AC16 복구 자료 보존 | PG: `AC15 and AC16 purge only completed payloads while preserving dedupe and every recoverable payload` | PENDING·PROCESSING·RETRYABLE·DEAD payload는 정리 대상에서 제외 |
|
|
113
|
+
| AC17 관측 실패 | PG: `AC17 isolates failing observation hooks from committed results and excludes payload content`; Worker: `isolates throwing, rejecting, and hanging observers from committed processing` | 실패·거절·미완료 observer가 처리 결과를 바꾸지 않음. payload·tenant 값과 임의 오류 내용의 관측 경로 노출 제한 |
|
|
114
|
+
| AC18 종료 drain | Worker: `bounds shutdown drain and aborts hanging callbacks`, `does not execute records returned by a claim that raced shutdown` | 종료와 claim 경합, 제한 시간·abort는 저장소 대역으로 검증. 실제 프로세스의 SIGTERM drain은 별도 장시간 운영 검증 필요 |
|
|
115
|
+
| AC19 입력·용량 제한 | Validation: `rejects cycles, excessive nesting and oversized multibyte payloads`, `stops serialization at the byte budget before walking the remaining payload`; PG: `AC19 keeps duplicate receipts available when admission is full` | JSON·길이·크기 제한과 설정된 backlog 상한 검증. DB 디스크 고갈은 미재현; admission 상한은 동시 수신 시 초과 가능한 근사치 |
|
|
116
|
+
| AC20 outbox 연계 | [outbox-to-inbox 예제](../examples/src/outbox-to-inbox/main.ts), Package의 public API transaction 테스트 | 실제 `@nestarc/outbox` 0.4.0으로 원 이벤트 → inbox → 업무 변경과 후속 outbox → 소비 결과를 확인하고 원 이벤트 재전달의 중복을 검사 |
|
|
117
|
+
| AC21 분리 배포와 pool | PG: `AC21 and AC23 claim only available slots and leave unowned backlog visible`; Worker: `reserves capacity across overlapping claims and frees slots after a batch settles` | consumer별 claim 범위와 동시성 예약을 검증. 실제 여러 배포의 DB connection pool 포화는 부하 시험으로 추가 확인 필요 |
|
|
118
|
+
| AC22 timeout과 정지 | PG: `AC22 closes timed-out transactions so an ignored deadline cannot write later`, `AC22 rolls back business writes when a SQL statement exceeds its timeout`; Worker: `serializes slow heartbeats and bounds their lifetime for a hung handler` | 실제 transaction·SQL 제한과 늦은 쓰기 거절, bounded heartbeat 검증. 네트워크를 강제로 멈춘 상태의 장기 pool 회복은 미재현 |
|
|
119
|
+
| AC23 담당 worker 부재 | PG: `AC21 and AC23 claim only available slots and leave unowned backlog visible` | 미담당 consumer가 대기 상태로 남고 backlog 통계에 드러남. 외부 경보 시스템 연결과 실제 배포 제거 절차는 앱 운영 영역 |
|
|
120
|
+
| AC24 완료 갱신 후 종료 | Crash: `AC24 rolls back business, outbox, and completion when killed after completion CAS but before COMMIT` | 실제 완료 CAS가 끝난 transaction의 COMMIT 직전에 프로세스를 종료하여 업무·후속 SQL·완료가 함께 롤백됨을 확인 |
|
|
121
|
+
| AC25 오래된 후속 쓰기 | PG: `AC25 rejects heartbeat and failure after lease expiry before any new claim`, `AC11 and AC25 allow one requeue and reject stale heartbeats and failures after it` | 만료 heartbeat의 소유권 부활과 requeue 이후 이전 token의 상태 덮어쓰기 거절 |
|
|
122
|
+
|
|
123
|
+
추가로 PG의 `rejects an incompatible schema version without silently repairing it`는 런타임이 migration을 자동 수정하지 않고 시작을 거절하는지 확인한다. Worker의 `releases capacity when reconciliation reads stall and never starts a late failure write`와 `does not hold a concurrency slot for a stalled failure update`는 결과 재확인 경로가 멈춰도 실행 슬롯을 무기한 점유하지 않는지 대역으로 검증한다.
|
|
124
|
+
|
|
125
|
+
## 패키지와 예제 검증
|
|
126
|
+
|
|
127
|
+
Package 검증은 `npm pack` 결과를 임시 consumer 앱에 `--strict-peer-deps`로 설치한다. 이 앱은 별도로 Prisma client를 생성하고 strict TypeScript 검사, CommonJS import, 동적 ESM import, SQL export, 실제 중복 수신·업무 롤백·범위별 관리 API를 실행한다. 저장소 소스를 직접 import하는 테스트와 구별되는 배포물 검사다.
|
|
128
|
+
|
|
129
|
+
새 tarball에 포함된 examples를 임시 폴더에 추출하고 자체 의존성을 설치한 뒤, PostgreSQL 16과 RabbitMQ 4에서 세 예제를 실행했다. 실제 outbox 체인은 projection과 downstream 처리 기록이 각각 1개임을 확인했다. [webhook 예제](../examples/src/webhook-ingress/main.ts)는 잘못된 HMAC의 401, 정상 수신의 202, 중복 수신의 200을 확인했다. [RabbitMQ 예제](../examples/src/rabbitmq-ingress/main.ts)는 브로커 전달 2회가 업무 결과 1개로 수렴함을 확인했다. 브로커 자체의 장애·재시작과 retry/DLQ 전체 경로의 장애 주입은 추가 운영 검증 대상이다.
|
|
130
|
+
|
|
131
|
+
## 남은 검증
|
|
132
|
+
|
|
133
|
+
- 로컬 호환성 matrix는 통과했으며 GitHub Actions의 Linux matrix 실행은 아직 확인하지 않았다. 모든 Node 패치·Prisma minor 조합을 검사한 것은 아니다.
|
|
134
|
+
- 실제 TCP 분리·DB failover·디스크 고갈·지속적인 pool 포화·장시간 종료 drain을 재현하는 운영 장애 시험은 남아 있다.
|
|
135
|
+
- [소규모 로컬 benchmark](benchmarks/2026-10-27-local.md)는 새 테이블·완료 기록 1만 개·중복 90% 시나리오의 수신 지연과 worker 처리를 측정했다. 실제 디스크 I/O, 장시간 backlog, 지속적인 pool 포화와 여러 번 반복한 용량 시험은 남아 있다.
|
|
136
|
+
- Preview.0 당시에는 최초 schema 설치와 잘못된 version 거절까지만 확인했다. 이후 Preview.1에서 v1→v2 migration과 기존 receipt 보존 검증을 추가했으며, 결과는 이 문서의 Preview.1 기록에 있다.
|
|
137
|
+
- M5의 실제 서비스 2곳 도입과 운영 피드백은 진행하지 않았다. npm 배포와 외부 도입 완료도 이 기록에 포함하지 않는다.
|
|
138
|
+
|
|
139
|
+
핵심 보장은 handler의 모든 업무 쓰기가 전달받은 동일 DB transaction을 사용하고 완료 identity가 보존되는 조건에 한정된다. 외부 HTTP 호출·결제·이메일·다른 DB의 효과는 이 테스트로 원자성이나 exactly-once를 보장하지 않는다.
|