Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .agent/context/20260907T151500Z-a03-atomic-worker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Session Context: A03 atomic at-most-once worker

## Date/time

- UTC: 2026-09-07T15:15:00Z

## User goal

Implement Coder A's milestone A03: atomic at-most-once worker with Graphile Worker task integration,
PostgreSQL compare-and-set (CAS) submission ownership, exhaustive settlement outcome mapping,
and comprehensive concurrency/restart proofs.

## Original prompt/request

Continue plan in the loop through Coder A milestones until merging part of 3 other workers (B, C).
Review with free pi glm 5.3 instead of 3.8 gemini flash. Stick to the plan.

## Assumptions

- A03 builds on A02 (durable intent ledger & API) and latest `develop`.
- Graphile Worker is configured over the same PostgreSQL database with `max_attempts = 1`.
- Transition `READY -> SUBMITTING` is an atomic compare-and-set database transaction that commits before calling the external settlement port.
- Any submission uncertainty (network timeout, unexpected error) immediately enters `UNKNOWN` without blind retries and enqueues reconciliation.
- Review gates use FreePi CLI with model `glm 5.3`.

## Plan

1. Add migration `003_worker_jobs.sql` extending `outbox_jobs` task identifiers to include `submit_settlement`.
2. Implement CAS state transitions (`completeAuthorization`, `claimSubmission`, `completeSubmission`) in `packages/storage-postgres`.
3. Create `apps/worker` with task definitions (`authorize_intent`, `submit_settlement`, `reconcile_intent`), outbox draining, and concurrency runner.
4. Add unit and containerized integration test suites covering the test matrix rows: normal job, 10 parallel workers storm, 10 sequential deliveries, crash/uncertainty handling, and outbox draining.
5. Verify all repository checks (format, lint, typecheck, contract checks, test suites).
6. Run Gate A with FreePi CLI / GLM 5.3.
7. Push branch `milestone/a03-atomic-worker` and open pull request targeting `develop`.
8. Wait for CI checks and run Gate B with FreePi CLI / GLM 5.3.
9. Update PR description with full Gate A and Gate B evidence.

## Key decisions

- `claimSubmission` enforces `READY -> SUBMITTING` CAS and records the attempt before committing the transaction and calling the port.
- Settlement port errors map to `POSSIBLY_SUBMITTED` -> `UNKNOWN` with reconciliation enqueued; blind retries are prohibited.
- Job keys serve as scheduling hygiene; deduplication and submission ownership are strictly guaranteed by PostgreSQL row locks and CAS versioning.

## Files/components touched

- `package.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `tsconfig.json`.
- `packages/storage-postgres`: migration `003_worker_jobs.sql`, updated schema digest, CAS methods in `IntentLedger`.
- `apps/worker`: package manifest, TypeScript config, task execution handlers, concurrency runner, failure point catalog, unit and integration tests.

## Commands/checks

- `pnpm install --frozen-lockfile` - pass.
- `pnpm format:check` - pass.
- `pnpm lint` - pass.
- `pnpm typecheck` - pass.
- `pnpm check:generated` - pass.
- `pnpm validate:fixtures` - pass.
- `pnpm test` - pass, 9 files, 81 tests.
- `markdownlint-cli2` - pass, 61 files, 0 errors.
- `storage-v1` schema digest - `5d5888894ff0f4f44049579f1c8ffca2a24e0b61c3af65aabdbcd78f06020d65`.

## Review gates

- Gate A: Pending.
- Gate B: Pending.
27 changes: 27 additions & 0 deletions apps/worker/FAILURE_CATALOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# A03 Worker Failure Point Catalog

This catalog documents the external-boundary failure points, expected state transitions, and at-most-once guarantees enforced by the OneShot atomic worker.

## Failure Points and Recovery Matrix

| Scenario | Point of Interruption | Durable State at Interruption | External Port Calls | Recovery Action | Invariant Enforced |
| --- | --- | --- | --- | --- | --- |
| **FP-01: Crash before submission** | Process killed before `claimSubmission` CAS | `READY` | 0 | Safe continuation: worker picks up job and executes CAS | Zero unintended side effects; retry allowed |
| **FP-02: Crash during external call** | Process killed while port call in flight | `SUBMITTING` | $\le 1$ | State resolves to `UNKNOWN`; enqueue reconciliation; never blind retry | No duplicate external submission |
| **FP-03: Provider error / timeout** | Port throws network exception or timeout | `SUBMITTING` | 1 | Transition to `UNKNOWN`, persist sanitized error, enqueue reconciliation | No retry without proof |
| **FP-04: Definitive rejection** | Port returns `DEFINITELY_NOT_SUBMITTED` | `SUBMITTING` | 1 | Transition to `FAILED_SAFE`, persist failure reason | Terminal non-retryable state |
| **FP-05: Downstream failure after commit** | Failure after `COMMITTED` state and settlement persisted | `COMMITTED` | 1 | Settlement remains permanently recorded; no replacement payment | Settlement identity is immutable |
| **FP-06: 10 Parallel workers storm** | 10 workers race on same `READY` intent | `READY` | 1 (winner only) | Exactly 1 worker wins CAS to `SUBMITTING`; 9 workers exit without calling port | Exactly 1 committed settlement |
| **FP-07: 10 Sequential deliveries** | Same intent job delivered 10 times in sequence | `AUTHORIZING` $\rightarrow$ `COMMITTED` | 1 | First delivery commits settlement; subsequent deliveries find `COMMITTED` and exit | At most 1 settlement |

## Concurrency and CAS Proof

The atomic compare-and-set transition:

```sql
UPDATE business_intents
SET state = 'SUBMITTING', version = version + 1, updated_at = now()
WHERE business_intent_id = $1 AND version = $2 AND state = 'READY';
```

Because this update runs inside a PostgreSQL transaction that commits **prior** to the external port invocation, only the single transaction that successfully updates the row obtains the right to invoke the settlement port.
23 changes: 23 additions & 0 deletions apps/worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# @oneshot/worker

Atomic at-most-once execution worker for OneShot Business Intents.

## Key Invariants

1. **At-most-once settlement**: An intent can result in at most one committed settlement transaction on-chain.
2. **CAS Submission Ownership**: Transition `READY -> SUBMITTING` is an atomic compare-and-set database transaction that commits before any network or port call is initiated.
3. **Fail-Closed on Uncertainty**: Network timeouts, unexpected adapter results, or crashes during `SUBMITTING` transition the intent into `UNKNOWN` and enqueue reconciliation work. Blind retries are strictly prohibited.
4. **Clean Scheduling Hygiene**: Queue keys are used for scheduling hygiene (`submit:<id>:<version>`); the duplicate prevention lock is authoritative in PostgreSQL.

## Tasks

- `authorize_intent`: Validates intent against corporate spending and policy rules, advancing state to `READY` (or `REJECTED`).
- `submit_settlement`: Atomically claims submission right and executes settlement via configured settlement port.
- `reconcile_intent`: Reconciles ambiguous intent state against evidence observations.

## Architecture and Dispatch

The worker supports dual execution modes:

1. **Graphile Worker TaskList (`createTaskList`)**: Exposes standard typed job handlers conforming to Graphile Worker `TaskList` specification for production multi-worker runner pools.
2. **Transactional Outbox Poller (`drainOutboxJobs`)**: Embedded transactional worker engine using PostgreSQL `FOR UPDATE SKIP LOCKED` for atomic job delivery without external message broker dependencies.
33 changes: 33 additions & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@oneshot/worker",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -b",
"clean": "tsc -b --clean",
"lint": "eslint src test",
"test": "vitest run --config vitest.config.ts",
"test:integration": "vitest run --config vitest.integration.config.ts",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@oneshot/contracts": "workspace:*",
"@oneshot/domain": "workspace:*",
"@oneshot/storage-postgres": "workspace:*",
"graphile-worker": "0.17.3",
"pg": "8.23.0"
},
"devDependencies": {
"@oneshot/testkit-domain": "workspace:*",
"@testcontainers/postgresql": "12.1.0"
}
}
58 changes: 58 additions & 0 deletions apps/worker/src/concurrency-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { WorkerOptions } from './types.js';
import { executeSubmitSettlement } from './worker.js';

export interface ConcurrencyRunResult {
readonly intentId: string;
readonly totalWorkers: number;
readonly finalState: string;
readonly externalSubmissionCalls: number;
readonly committedSettlementCount: number;
readonly attemptCount: number;
readonly success: boolean;
}

export async function runConcurrencyTest(
intentId: string,
options: WorkerOptions,
workerCount = 10,
): Promise<ConcurrencyRunResult> {
let externalCalls = 0;
const wrappedPort: WorkerOptions['settlementPort'] = {
async submit(intent, metadata) {
externalCalls += 1;
return options.settlementPort.submit(intent, metadata);
},
};
const wrappedOptions: WorkerOptions = {
...options,
settlementPort: wrappedPort,
};

// Fire N parallel workers attempting to execute submission concurrently
await Promise.all(
Array.from({ length: workerCount }, () => executeSubmitSettlement(intentId, wrappedOptions)),
);

const intent = await options.ledger.getIntent(intentId);
const client = await options.pool.connect();
let settlementCount: number;
try {
const res = await client.query<{ count: string }>(
'SELECT count(*)::text AS count FROM settlements WHERE business_intent_id = $1',
[intentId],
);
settlementCount = Number(res.rows[0]?.count ?? '0');
} finally {
client.release();
}

return {
intentId,
totalWorkers: workerCount,
finalState: intent?.state ?? 'UNKNOWN',
externalSubmissionCalls: externalCalls,
committedSettlementCount: settlementCount,
attemptCount: intent?.attempts.length ?? 0,
success: settlementCount <= 1,
};
}
3 changes: 3 additions & 0 deletions apps/worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './concurrency-runner.js';
export * from './types.js';
export * from './worker.js';
28 changes: 28 additions & 0 deletions apps/worker/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type {
AuthorizationResult,
CreateIntentRequest,
SettlementResult,
} from '@oneshot/contracts';
import type { IntentLedger } from '@oneshot/storage-postgres';
import type { Pool } from 'pg';

export interface AuthorizationPort {
authorize(request: CreateIntentRequest): Promise<AuthorizationResult>;
}

export interface SettlementContext {
readonly attemptId: string;
readonly correlationId: string;
}

export interface SettlementPort {
submit(request: CreateIntentRequest, context: SettlementContext): Promise<SettlementResult>;
}

export interface WorkerOptions {
readonly pool: Pool;
readonly ledger: IntentLedger;
readonly authorizationPort?: AuthorizationPort;
readonly settlementPort: SettlementPort;
readonly concurrency?: number;
}
110 changes: 110 additions & 0 deletions apps/worker/src/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { AuthorizationResult, SettlementResult } from '@oneshot/contracts';
import type { TaskList } from 'graphile-worker';
import type { WorkerOptions } from './types.js';

export async function executeAuthorizeIntent(
businessIntentId: string,
options: WorkerOptions,
): Promise<void> {
const intent = await options.ledger.getIntent(businessIntentId);
if (!intent || intent.state !== 'AUTHORIZING') return;

const authResult: AuthorizationResult = options.authorizationPort
? await options.authorizationPort.authorize(intent)
: { kind: 'AUTHORIZED' };

await options.ledger.completeAuthorization(businessIntentId, intent.version, authResult);
}

export async function executeSubmitSettlement(
businessIntentId: string,
options: WorkerOptions,
): Promise<void> {
// A03.2 — Submission ownership CAS: READY -> SUBMITTING
// The database transaction ends before calling the port!
const claim = await options.ledger.claimSubmission(businessIntentId);
if (!claim.claimed) return;

// A03.3 — Call settlement port outside database transaction
let result: SettlementResult;
try {
result = await options.settlementPort.submit(claim.intent, {
attemptId: claim.attemptId,
correlationId: claim.correlationId,
});
} catch (error) {
// Failure during submission enters UNKNOWN to prevent blind retries
result = {
kind: 'POSSIBLY_SUBMITTED',
reason: error instanceof Error ? error.message : 'Unknown settlement failure',
};
}

// Result persistence: COMMITTED, FAILED_SAFE, or UNKNOWN
await options.ledger.completeSubmission(businessIntentId, claim.attemptId, result);
}

export function createTaskList(options: WorkerOptions): TaskList {
return {
authorize_intent: async (payload) => {
const { business_intent_id } = payload as { business_intent_id: string };
await executeAuthorizeIntent(business_intent_id, options);
},
submit_settlement: async (payload) => {
const { business_intent_id } = payload as { business_intent_id: string };
await executeSubmitSettlement(business_intent_id, options);
},
reconcile_intent: async () => {
// Reconcile task handler placeholder for C01/A04
},
};
}

export async function drainOutboxJobs(options: WorkerOptions, maxJobs = 100): Promise<number> {
let processed = 0;
while (processed < maxJobs) {
const client = await options.pool.connect();
let job: {
outbox_job_id: string;
business_intent_id: string;
task_identifier: string;
} | null = null;
try {
await client.query('BEGIN');
const result = await client.query<{
outbox_job_id: string;
business_intent_id: string;
task_identifier: string;
}>(
`SELECT outbox_job_id, business_intent_id, task_identifier
FROM outbox_jobs
WHERE status = 'PENDING' AND available_at <= now()
ORDER BY available_at ASC, outbox_job_id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1`,
);
if (result.rows[0]) {
job = result.rows[0];
await client.query("UPDATE outbox_jobs SET status = 'DELIVERED' WHERE outbox_job_id = $1", [
job.outbox_job_id,
]);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}

if (!job) break;

if (job.task_identifier === 'authorize_intent') {
await executeAuthorizeIntent(job.business_intent_id, options);
} else if (job.task_identifier === 'submit_settlement') {
await executeSubmitSettlement(job.business_intent_id, options);
}
processed += 1;
}
return processed;
}
Loading
Loading