Initial Commit
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
DistributedTask,
|
||||
ExecutionContext,
|
||||
TaskMetadata,
|
||||
TaskStatusValue,
|
||||
} from "./task.js";
|
||||
export {
|
||||
TaskMetadataSchema,
|
||||
TaskStatus,
|
||||
TaskStatusSchema,
|
||||
} from "./task.js";
|
||||
80
packages/foundation/flow/queue-distribution/src/task/task.md
Normal file
80
packages/foundation/flow/queue-distribution/src/task/task.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# `DistributedTask` contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DistributedTask<I, O>` is the unit of work the rest of `@suaveplan/queue-distribution` executes. It is deliberately decoupled from `@suaveplan/queue`'s `Job` / state-machine model so the distribution layer can serve queue, batch, RPC, or agent-dispatch consumers via thin adapters. The contract is generic over an input `I` and an output `O` so a caller's static types flow end-to-end through `Worker.executeTask`.
|
||||
|
||||
## Features
|
||||
|
||||
- `id: string` — caller-assigned identity used in logs, metrics, retry tracking.
|
||||
- `execute(input, context): Promise<O>` — the work itself. `context` is a caller-defined `ExecutionContext`.
|
||||
- `TaskStatus` enum (`PENDING | RUNNING | COMPLETED | FAILED | CANCELLED | TIMED_OUT`) + `TaskStatusSchema` for Zod validation.
|
||||
- `TaskMetadata` shape (`createdAt`, optional `startedAt` / `completedAt`, `attempts`, optional `tags`, indexed extras) + `TaskMetadataSchema`.
|
||||
- `setStatus` / `getStatus` / `getMetadata` for lifecycle observation by the worker.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
type DistributedTask,
|
||||
TaskStatus,
|
||||
type TaskStatusValue,
|
||||
} from "@suaveplan/queue-distribution/task/task";
|
||||
|
||||
function makeTask(): DistributedTask<number, string> {
|
||||
let status: TaskStatusValue = TaskStatus.PENDING;
|
||||
return {
|
||||
id: "double-then-stringify",
|
||||
execute: async (n) => String(n * 2),
|
||||
getStatus: () => status,
|
||||
setStatus: (s) => {
|
||||
status = s;
|
||||
},
|
||||
getMetadata: () => ({ createdAt: new Date(), attempts: 0 }),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
`DistributedTask` is the seam where consumers bridge their own work model. For a `@suaveplan/queue` consumer, wrap a `Job` so each retry advances `attempts` and propagates failure into the queue's state machine. For RPC, encode the remote-call envelope in `execute`.
|
||||
|
||||
```ts
|
||||
import type { DistributedTask } from "@suaveplan/queue-distribution/task/task";
|
||||
import { TaskMetadataSchema } from "@suaveplan/queue-distribution/task/task";
|
||||
|
||||
class RpcTask<I, O> implements DistributedTask<I, O> {
|
||||
constructor(readonly id: string, private fetcher: (i: I) => Promise<O>) {}
|
||||
#status = "PENDING" as const;
|
||||
#metadata = { createdAt: new Date(), attempts: 0 };
|
||||
async execute(input: I): Promise<O> {
|
||||
this.#metadata.attempts += 1;
|
||||
return this.fetcher(input);
|
||||
}
|
||||
getStatus = () => this.#status;
|
||||
setStatus = (s: typeof this.#status): void => {
|
||||
this.#status = s;
|
||||
};
|
||||
getMetadata = () => {
|
||||
const parsed = TaskMetadataSchema.parse(this.#metadata);
|
||||
return parsed;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface DistributedTask<I = unknown, O = unknown>` — fields above.
|
||||
- `interface ExecutionContext` — opaque marker; callers define the concrete shape.
|
||||
- `const TaskStatus` — frozen literal map.
|
||||
- `type TaskStatusValue` — union of `TaskStatus` values.
|
||||
- `const TaskStatusSchema: z.ZodType<TaskStatusValue>` — Zod enum.
|
||||
- `interface TaskMetadata` — `createdAt: Date`, `startedAt?: Date`, `completedAt?: Date`, `attempts: number`, `tags?: readonly string[]`, indexed extras.
|
||||
- `const TaskMetadataSchema: z.ZodType<TaskMetadata>` — Zod object with `passthrough` for extras.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`TaskMetadataSchema` uses `passthrough` because the indexed-extras signature on `TaskMetadata` is intentional: callers can carry arbitrary diagnostic context (queue name, trace id, retry deadline) without ratcheting the schema each time. The schema enforces only the typed fields. `ExecutionContext` is an empty interface — a marker rather than a contract — so callers can pass any object (request scope, tenant id, AbortSignal, dependency container). The distribution layer never inspects the context.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
type DistributedTask,
|
||||
TaskMetadataSchema,
|
||||
TaskStatus,
|
||||
TaskStatusSchema,
|
||||
type TaskStatusValue,
|
||||
} from "./task.js";
|
||||
|
||||
function makeTask(): DistributedTask<number, string> {
|
||||
let status: TaskStatusValue = TaskStatus.PENDING;
|
||||
return {
|
||||
id: "t-1",
|
||||
execute: async (input) => String(input),
|
||||
getStatus: () => status,
|
||||
setStatus: (s) => {
|
||||
status = s;
|
||||
},
|
||||
getMetadata: () => ({
|
||||
createdAt: new Date(0),
|
||||
attempts: 0,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("DistributedTask contract", () => {
|
||||
it("validates TaskMetadata via TaskMetadataSchema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const task = makeTask();
|
||||
expect(() =>
|
||||
TaskMetadataSchema.parse(task.getMetadata())
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects malformed TaskMetadata via Zod", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
TaskMetadataSchema.parse({ createdAt: "not-a-date", attempts: -1 })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts every TaskStatus value", { timeout: 5000 }, () => {
|
||||
for (const value of Object.values(TaskStatus)) {
|
||||
expect(() => TaskStatusSchema.parse(value)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown TaskStatus values", { timeout: 5000 }, () => {
|
||||
expect(() => TaskStatusSchema.parse("BANANA")).toThrow();
|
||||
});
|
||||
|
||||
it("setStatus + getStatus round-trip", { timeout: 5000 }, () => {
|
||||
const task = makeTask();
|
||||
expect(task.getStatus()).toBe(TaskStatus.PENDING);
|
||||
task.setStatus(TaskStatus.RUNNING);
|
||||
expect(task.getStatus()).toBe(TaskStatus.RUNNING);
|
||||
});
|
||||
});
|
||||
71
packages/foundation/flow/queue-distribution/src/task/task.ts
Normal file
71
packages/foundation/flow/queue-distribution/src/task/task.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Lifecycle states for a `DistributedTask`. The transitions are linear:
|
||||
* `PENDING → RUNNING → (COMPLETED | FAILED | CANCELLED | TIMED_OUT)`.
|
||||
*/
|
||||
export const TaskStatus = {
|
||||
PENDING: "PENDING",
|
||||
RUNNING: "RUNNING",
|
||||
COMPLETED: "COMPLETED",
|
||||
FAILED: "FAILED",
|
||||
CANCELLED: "CANCELLED",
|
||||
TIMED_OUT: "TIMED_OUT",
|
||||
} as const;
|
||||
|
||||
export type TaskStatusValue = (typeof TaskStatus)[keyof typeof TaskStatus];
|
||||
|
||||
/** Zod schema for `TaskStatus`. */
|
||||
export const TaskStatusSchema = z.enum([
|
||||
TaskStatus.PENDING,
|
||||
TaskStatus.RUNNING,
|
||||
TaskStatus.COMPLETED,
|
||||
TaskStatus.FAILED,
|
||||
TaskStatus.CANCELLED,
|
||||
TaskStatus.TIMED_OUT,
|
||||
]);
|
||||
|
||||
/** Metadata carried with every `DistributedTask`. Indexed extras are allowed. */
|
||||
export interface TaskMetadata {
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
attempts: number;
|
||||
tags?: readonly string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Zod schema for `TaskMetadata`. */
|
||||
export const TaskMetadataSchema: z.ZodType<TaskMetadata> = z
|
||||
.object({
|
||||
createdAt: z.date(),
|
||||
startedAt: z.date().optional(),
|
||||
completedAt: z.date().optional(),
|
||||
attempts: z.number().int().nonnegative(),
|
||||
tags: z.array(z.string()).readonly().optional(),
|
||||
})
|
||||
.passthrough() as z.ZodType<TaskMetadata>;
|
||||
|
||||
/**
|
||||
* Opaque execution context threaded through `task.execute`. Callers may use
|
||||
* any object — distribution never inspects it. Use this to thread request
|
||||
* scope, tenant id, AbortSignal, dependencies, etc. into the unit of work.
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noEmptyInterface: opaque marker — see docstring.
|
||||
export interface ExecutionContext {}
|
||||
|
||||
/**
|
||||
* The unit of work executed by a `Worker`. Generic over input `I` and output
|
||||
* `O` so callers retain end-to-end typing through `worker.executeTask`.
|
||||
*
|
||||
* Tasks are mutable: `setStatus` advances the lifecycle, `getStatus` /
|
||||
* `getMetadata` observe it. Implementations MUST clamp `attempts` to non-
|
||||
* negative integers and SHOULD treat `createdAt` as set-once.
|
||||
*/
|
||||
export interface DistributedTask<I = unknown, O = unknown> {
|
||||
readonly id: string;
|
||||
execute(input: I, context: ExecutionContext): Promise<O>;
|
||||
getStatus(): TaskStatusValue;
|
||||
setStatus(status: TaskStatusValue): void;
|
||||
getMetadata(): TaskMetadata;
|
||||
}
|
||||
Reference in New Issue
Block a user