Compare commits
8 Commits
main
...
feat/core-
| Author | SHA1 | Date | |
|---|---|---|---|
| 67fcb2e60c | |||
| 119fbdf801 | |||
| 869f9b49df | |||
| 9b2db93afc | |||
| 5049d04977 | |||
| a235aebf20 | |||
| 79b9617045 | |||
| f2435471af |
12
ARCHIVED.md
12
ARCHIVED.md
@@ -1,12 +0,0 @@
|
|||||||
# ⚠️ This repo has been archived
|
|
||||||
|
|
||||||
**Migrated to:** [`mosaic/mosaic`](https://git.mosaicstack.dev/mosaic/mosaic) — `packages/queue/`
|
|
||||||
**Package:** `@mosaic/queue`
|
|
||||||
**Date:** 2026-03-06
|
|
||||||
|
|
||||||
Install via:
|
|
||||||
```bash
|
|
||||||
npm install @mosaic/queue --registry https://git.mosaicstack.dev/api/packages/mosaic/npm
|
|
||||||
```
|
|
||||||
|
|
||||||
All future development happens in the monorepo. This repo is read-only.
|
|
||||||
@@ -83,7 +83,7 @@ lanes:
|
|||||||
watchdog:
|
watchdog:
|
||||||
interval: 60
|
interval: 60
|
||||||
openbrain:
|
openbrain:
|
||||||
url: ${OPENBRAIN_URL} # required — no default
|
url: https://brain.woltje.com
|
||||||
autoCapture: false
|
autoCapture: false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,6 @@ const LANE_SET = new Set<TaskLane>(TASK_LANES);
|
|||||||
|
|
||||||
const DEFAULT_KEY_PREFIX = 'mosaic:queue';
|
const DEFAULT_KEY_PREFIX = 'mosaic:queue';
|
||||||
const MAX_ATOMIC_RETRIES = 8;
|
const MAX_ATOMIC_RETRIES = 8;
|
||||||
const UPDATE_ALLOWED_STATUS_TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {
|
|
||||||
pending: ['blocked'],
|
|
||||||
blocked: ['pending'],
|
|
||||||
claimed: ['in-progress'],
|
|
||||||
'in-progress': ['claimed'],
|
|
||||||
completed: [],
|
|
||||||
failed: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RepositoryKeys {
|
interface RepositoryKeys {
|
||||||
readonly taskIds: string;
|
readonly taskIds: string;
|
||||||
@@ -33,7 +25,6 @@ interface RepositoryKeys {
|
|||||||
|
|
||||||
export interface RedisTaskClient {
|
export interface RedisTaskClient {
|
||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
mget(...keys: string[]): Promise<(string | null)[]>;
|
|
||||||
set(key: string, value: string, mode?: 'NX' | 'XX'): Promise<'OK' | null>;
|
set(key: string, value: string, mode?: 'NX' | 'XX'): Promise<'OK' | null>;
|
||||||
smembers(key: string): Promise<string[]>;
|
smembers(key: string): Promise<string[]>;
|
||||||
sadd(key: string, member: string): Promise<number>;
|
sadd(key: string, member: string): Promise<number>;
|
||||||
@@ -153,50 +144,19 @@ export class RedisTaskRepository {
|
|||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskKey = this.keys.task(task.taskId);
|
const saveResult = await this.client.set(
|
||||||
const serializedTask = JSON.stringify(task);
|
this.keys.task(task.taskId),
|
||||||
|
JSON.stringify(task),
|
||||||
|
'NX',
|
||||||
|
);
|
||||||
|
|
||||||
for (let attempt = 0; attempt < MAX_ATOMIC_RETRIES; attempt += 1) {
|
if (saveResult !== 'OK') {
|
||||||
await this.client.watch(taskKey);
|
throw new TaskAlreadyExistsError(task.taskId);
|
||||||
|
|
||||||
try {
|
|
||||||
const transaction = this.client.multi();
|
|
||||||
transaction.set(taskKey, serializedTask, 'NX');
|
|
||||||
transaction.sadd(this.keys.taskIds, task.taskId);
|
|
||||||
const execResult = await transaction.exec();
|
|
||||||
|
|
||||||
if (execResult === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const setResult = execResult[0];
|
|
||||||
|
|
||||||
if (setResult === undefined) {
|
|
||||||
throw new TaskAtomicConflictError(task.taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [setError, setReply] = setResult;
|
|
||||||
|
|
||||||
if (setError !== null) {
|
|
||||||
throw setError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setReply !== 'OK') {
|
|
||||||
throw new TaskAlreadyExistsError(task.taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const saddResult = execResult[1];
|
|
||||||
if (saddResult !== undefined && saddResult[0] !== null) {
|
|
||||||
throw saddResult[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return task;
|
|
||||||
} finally {
|
|
||||||
await this.client.unwatch();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TaskAlreadyExistsError(task.taskId);
|
await this.client.sadd(this.keys.taskIds, task.taskId);
|
||||||
|
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(taskId: string): Promise<Task | null> {
|
public async get(taskId: string): Promise<Task | null> {
|
||||||
@@ -211,28 +171,8 @@ export class RedisTaskRepository {
|
|||||||
|
|
||||||
public async list(filters: TaskListFilters = {}): Promise<Task[]> {
|
public async list(filters: TaskListFilters = {}): Promise<Task[]> {
|
||||||
const taskIds = await this.client.smembers(this.keys.taskIds);
|
const taskIds = await this.client.smembers(this.keys.taskIds);
|
||||||
|
const records = await Promise.all(taskIds.map(async (taskId) => this.get(taskId)));
|
||||||
if (taskIds.length === 0) {
|
const tasks = records.filter((task): task is Task => task !== null);
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskKeys = taskIds.map((taskId) => this.keys.task(taskId));
|
|
||||||
const records = await this.client.mget(...taskKeys);
|
|
||||||
const tasks: Task[] = [];
|
|
||||||
|
|
||||||
for (const [index, rawTask] of records.entries()) {
|
|
||||||
if (rawTask === null || rawTask === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskId = taskIds[index];
|
|
||||||
|
|
||||||
if (taskId === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.push(deserializeTask(taskId, rawTask));
|
|
||||||
}
|
|
||||||
|
|
||||||
return tasks
|
return tasks
|
||||||
.filter((task) =>
|
.filter((task) =>
|
||||||
@@ -246,17 +186,33 @@ export class RedisTaskRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async update(taskId: string, patch: TaskUpdateInput): Promise<Task> {
|
public async update(taskId: string, patch: TaskUpdateInput): Promise<Task> {
|
||||||
return this.mutateTaskAtomically(taskId, (existing, now) => {
|
const existing = await this.get(taskId);
|
||||||
assertUpdatePatchIsAllowed(taskId, existing, patch);
|
|
||||||
|
|
||||||
return {
|
if (existing === null) {
|
||||||
...existing,
|
throw new TaskNotFoundError(taskId);
|
||||||
...patch,
|
}
|
||||||
dependencies:
|
|
||||||
patch.dependencies === undefined ? existing.dependencies : [...patch.dependencies],
|
const updated: Task = {
|
||||||
updatedAt: now,
|
...existing,
|
||||||
};
|
...patch,
|
||||||
});
|
dependencies:
|
||||||
|
patch.dependencies === undefined ? existing.dependencies : [...patch.dependencies],
|
||||||
|
updatedAt: this.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveResult = await this.client.set(
|
||||||
|
this.keys.task(taskId),
|
||||||
|
JSON.stringify(updated),
|
||||||
|
'XX',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (saveResult !== 'OK') {
|
||||||
|
throw new TaskNotFoundError(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.client.sadd(this.keys.taskIds, taskId);
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async claim(taskId: string, input: ClaimTaskInput): Promise<Task> {
|
public async claim(taskId: string, input: ClaimTaskInput): Promise<Task> {
|
||||||
@@ -402,26 +358,6 @@ export class RedisTaskRepository {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setResult = execResult[0];
|
|
||||||
if (setResult === undefined) {
|
|
||||||
throw new TaskAtomicConflictError(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [setError, setReply] = setResult;
|
|
||||||
|
|
||||||
if (setError !== null) {
|
|
||||||
throw setError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setReply !== 'OK') {
|
|
||||||
throw new TaskNotFoundError(taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const saddResult = execResult[1];
|
|
||||||
if (saddResult !== undefined && saddResult[0] !== null) {
|
|
||||||
throw saddResult[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
} finally {
|
} finally {
|
||||||
await this.client.unwatch();
|
await this.client.unwatch();
|
||||||
@@ -448,33 +384,6 @@ function matchesFilters(task: Task, filters: TaskListFilters): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertUpdatePatchIsAllowed(taskId: string, task: Task, patch: TaskUpdateInput): void {
|
|
||||||
if (patch.status !== undefined && !canTransitionStatusViaUpdate(task.status, patch.status)) {
|
|
||||||
throw new TaskTransitionError(taskId, task.status, 'update');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
patch.claimedBy !== undefined ||
|
|
||||||
patch.claimedAt !== undefined ||
|
|
||||||
patch.claimTTL !== undefined ||
|
|
||||||
patch.completedAt !== undefined ||
|
|
||||||
patch.failedAt !== undefined ||
|
|
||||||
patch.failureReason !== undefined ||
|
|
||||||
patch.completionSummary !== undefined ||
|
|
||||||
patch.retryCount !== undefined
|
|
||||||
) {
|
|
||||||
throw new TaskTransitionError(taskId, task.status, 'update');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canTransitionStatusViaUpdate(from: TaskStatus, to: TaskStatus): boolean {
|
|
||||||
if (from === to) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return UPDATE_ALLOWED_STATUS_TRANSITIONS[from].includes(to);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canClaimTask(task: Task, now: number): boolean {
|
function canClaimTask(task: Task, now: number): boolean {
|
||||||
if (task.status === 'pending') {
|
if (task.status === 'pending') {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
RedisTaskRepository,
|
RedisTaskRepository,
|
||||||
TaskAlreadyExistsError,
|
|
||||||
TaskOwnershipError,
|
TaskOwnershipError,
|
||||||
TaskTransitionError,
|
TaskTransitionError,
|
||||||
type RedisTaskClient,
|
type RedisTaskClient,
|
||||||
@@ -113,10 +112,6 @@ class InMemoryAtomicRedisClient implements RedisTaskClient {
|
|||||||
return Promise.resolve(this.backend.kv.get(key) ?? null);
|
return Promise.resolve(this.backend.kv.get(key) ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public mget(...keys: string[]): Promise<(string | null)[]> {
|
|
||||||
return Promise.resolve(keys.map((key) => this.backend.kv.get(key) ?? null));
|
|
||||||
}
|
|
||||||
|
|
||||||
public set(
|
public set(
|
||||||
key: string,
|
key: string,
|
||||||
value: string,
|
value: string,
|
||||||
@@ -172,25 +167,6 @@ class InMemoryAtomicRedisClient implements RedisTaskClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class StrictAtomicRedisClient extends InMemoryAtomicRedisClient {
|
|
||||||
public override set(
|
|
||||||
key: string,
|
|
||||||
value: string,
|
|
||||||
mode?: 'NX' | 'XX',
|
|
||||||
): Promise<'OK' | null> {
|
|
||||||
void key;
|
|
||||||
void value;
|
|
||||||
void mode;
|
|
||||||
throw new Error('Direct set() is not allowed in strict atomic tests.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public override sadd(key: string, member: string): Promise<number> {
|
|
||||||
void key;
|
|
||||||
void member;
|
|
||||||
throw new Error('Direct sadd() is not allowed in strict atomic tests.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRepositoryPair(now: () => number): [RedisTaskRepository, RedisTaskRepository] {
|
function createRepositoryPair(now: () => number): [RedisTaskRepository, RedisTaskRepository] {
|
||||||
const backend = new InMemoryRedisBackend();
|
const backend = new InMemoryRedisBackend();
|
||||||
|
|
||||||
@@ -206,56 +182,7 @@ function createRepositoryPair(now: () => number): [RedisTaskRepository, RedisTas
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStrictRepositoryPair(
|
|
||||||
now: () => number,
|
|
||||||
): [RedisTaskRepository, RedisTaskRepository] {
|
|
||||||
const backend = new InMemoryRedisBackend();
|
|
||||||
|
|
||||||
return [
|
|
||||||
new RedisTaskRepository({
|
|
||||||
client: new StrictAtomicRedisClient(backend),
|
|
||||||
now,
|
|
||||||
}),
|
|
||||||
new RedisTaskRepository({
|
|
||||||
client: new StrictAtomicRedisClient(backend),
|
|
||||||
now,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RedisTaskRepository atomic transitions', () => {
|
describe('RedisTaskRepository atomic transitions', () => {
|
||||||
it('creates atomically under concurrent create race', async () => {
|
|
||||||
const [repositoryA, repositoryB] = createStrictRepositoryPair(
|
|
||||||
() => 1_700_000_000_000,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [createA, createB] = await Promise.allSettled([
|
|
||||||
repositoryA.create({
|
|
||||||
project: 'queue',
|
|
||||||
mission: 'phase1',
|
|
||||||
taskId: 'MQ-004-CREATE',
|
|
||||||
title: 'create race',
|
|
||||||
}),
|
|
||||||
repositoryB.create({
|
|
||||||
project: 'queue',
|
|
||||||
mission: 'phase1',
|
|
||||||
taskId: 'MQ-004-CREATE',
|
|
||||||
title: 'create race duplicate',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const fulfilled = [createA, createB].filter(
|
|
||||||
(result) => result.status === 'fulfilled',
|
|
||||||
);
|
|
||||||
const rejected = [createA, createB].filter(
|
|
||||||
(result) => result.status === 'rejected',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fulfilled).toHaveLength(1);
|
|
||||||
expect(rejected).toHaveLength(1);
|
|
||||||
expect(rejected[0]?.reason).toBeInstanceOf(TaskAlreadyExistsError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('claims a pending task once and blocks concurrent double-claim', async () => {
|
it('claims a pending task once and blocks concurrent double-claim', async () => {
|
||||||
let timestamp = 1_700_000_000_000;
|
let timestamp = 1_700_000_000_000;
|
||||||
const now = (): number => timestamp;
|
const now = (): number => timestamp;
|
||||||
@@ -429,31 +356,4 @@ describe('RedisTaskRepository atomic transitions', () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(TaskOwnershipError);
|
).rejects.toBeInstanceOf(TaskOwnershipError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges concurrent non-conflicting update patches atomically', async () => {
|
|
||||||
const [repositoryA, repositoryB] = createRepositoryPair(() => 1_700_000_000_000);
|
|
||||||
|
|
||||||
await repositoryA.create({
|
|
||||||
project: 'queue',
|
|
||||||
mission: 'phase1',
|
|
||||||
taskId: 'MQ-004-UPD',
|
|
||||||
title: 'Original title',
|
|
||||||
description: 'Original description',
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
repositoryA.update('MQ-004-UPD', {
|
|
||||||
title: 'Updated title',
|
|
||||||
}),
|
|
||||||
repositoryB.update('MQ-004-UPD', {
|
|
||||||
description: 'Updated description',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const latest = await repositoryA.get('MQ-004-UPD');
|
|
||||||
|
|
||||||
expect(latest).not.toBeNull();
|
|
||||||
expect(latest?.title).toBe('Updated title');
|
|
||||||
expect(latest?.description).toBe('Updated description');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,81 +3,21 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
RedisTaskRepository,
|
RedisTaskRepository,
|
||||||
TaskAlreadyExistsError,
|
TaskAlreadyExistsError,
|
||||||
TaskTransitionError,
|
|
||||||
type RedisTaskClient,
|
type RedisTaskClient,
|
||||||
type RedisTaskTransaction,
|
type RedisTaskTransaction,
|
||||||
} from '../src/task-repository.js';
|
} from '../src/task-repository.js';
|
||||||
|
|
||||||
class NoopRedisTransaction implements RedisTaskTransaction {
|
class NoopRedisTransaction implements RedisTaskTransaction {
|
||||||
private readonly operations: (
|
public set(): RedisTaskTransaction {
|
||||||
| {
|
|
||||||
readonly type: 'set';
|
|
||||||
readonly key: string;
|
|
||||||
readonly value: string;
|
|
||||||
readonly mode?: 'NX' | 'XX';
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly type: 'sadd';
|
|
||||||
readonly key: string;
|
|
||||||
readonly member: string;
|
|
||||||
}
|
|
||||||
)[] = [];
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
private readonly kv: Map<string, string>,
|
|
||||||
private readonly sets: Map<string, Set<string>>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public set(key: string, value: string, mode?: 'NX' | 'XX'): RedisTaskTransaction {
|
|
||||||
this.operations.push({
|
|
||||||
type: 'set',
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
mode,
|
|
||||||
});
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sadd(key: string, member: string): RedisTaskTransaction {
|
public sadd(): RedisTaskTransaction {
|
||||||
this.operations.push({
|
|
||||||
type: 'sadd',
|
|
||||||
key,
|
|
||||||
member,
|
|
||||||
});
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public exec(): Promise<readonly (readonly [Error | null, unknown])[] | null> {
|
public exec(): Promise<readonly (readonly [Error | null, unknown])[] | null> {
|
||||||
const results: (readonly [Error | null, unknown])[] = [];
|
return Promise.resolve([]);
|
||||||
|
|
||||||
for (const operation of this.operations) {
|
|
||||||
if (operation.type === 'set') {
|
|
||||||
const exists = this.kv.has(operation.key);
|
|
||||||
|
|
||||||
if (operation.mode === 'NX' && exists) {
|
|
||||||
results.push([null, null]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation.mode === 'XX' && !exists) {
|
|
||||||
results.push([null, null]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.kv.set(operation.key, operation.value);
|
|
||||||
results.push([null, 'OK']);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = this.sets.get(operation.key) ?? new Set<string>();
|
|
||||||
const beforeSize = values.size;
|
|
||||||
|
|
||||||
values.add(operation.member);
|
|
||||||
this.sets.set(operation.key, values);
|
|
||||||
results.push([null, values.size === beforeSize ? 0 : 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve(results);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,10 +29,6 @@ class InMemoryRedisClient implements RedisTaskClient {
|
|||||||
return Promise.resolve(this.kv.get(key) ?? null);
|
return Promise.resolve(this.kv.get(key) ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public mget(...keys: string[]): Promise<(string | null)[]> {
|
|
||||||
return Promise.resolve(keys.map((key) => this.kv.get(key) ?? null));
|
|
||||||
}
|
|
||||||
|
|
||||||
public set(
|
public set(
|
||||||
key: string,
|
key: string,
|
||||||
value: string,
|
value: string,
|
||||||
@@ -135,24 +71,7 @@ class InMemoryRedisClient implements RedisTaskClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public multi(): RedisTaskTransaction {
|
public multi(): RedisTaskTransaction {
|
||||||
return new NoopRedisTransaction(this.kv, this.sets);
|
return new NoopRedisTransaction();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MgetTrackingRedisClient extends InMemoryRedisClient {
|
|
||||||
public getCalls = 0;
|
|
||||||
public mgetCalls = 0;
|
|
||||||
public lastMgetKeys: string[] = [];
|
|
||||||
|
|
||||||
public override get(key: string): Promise<string | null> {
|
|
||||||
this.getCalls += 1;
|
|
||||||
return super.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override mget(...keys: string[]): Promise<(string | null)[]> {
|
|
||||||
this.mgetCalls += 1;
|
|
||||||
this.lastMgetKeys = [...keys];
|
|
||||||
return super.mget(...keys);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,9 +141,8 @@ describe('RedisTaskRepository CRUD', () => {
|
|||||||
title: 'Claimed task',
|
title: 'Claimed task',
|
||||||
});
|
});
|
||||||
|
|
||||||
await repository.claim('MQ-003B', {
|
await repository.update('MQ-003B', {
|
||||||
agentId: 'agent-a',
|
status: 'claimed',
|
||||||
ttlSeconds: 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const byProject = await repository.list({
|
const byProject = await repository.list({
|
||||||
@@ -242,39 +160,6 @@ describe('RedisTaskRepository CRUD', () => {
|
|||||||
expect(byStatus.map((task) => task.taskId)).toEqual(['MQ-003B']);
|
expect(byStatus.map((task) => task.taskId)).toEqual(['MQ-003B']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lists 3+ tasks with a single mget call', async () => {
|
|
||||||
const client = new MgetTrackingRedisClient();
|
|
||||||
const repository = new RedisTaskRepository({
|
|
||||||
client,
|
|
||||||
});
|
|
||||||
|
|
||||||
await repository.create({
|
|
||||||
project: 'queue',
|
|
||||||
mission: 'phase-list',
|
|
||||||
taskId: 'MQ-MGET-001',
|
|
||||||
title: 'Task one',
|
|
||||||
});
|
|
||||||
await repository.create({
|
|
||||||
project: 'queue',
|
|
||||||
mission: 'phase-list',
|
|
||||||
taskId: 'MQ-MGET-002',
|
|
||||||
title: 'Task two',
|
|
||||||
});
|
|
||||||
await repository.create({
|
|
||||||
project: 'queue',
|
|
||||||
mission: 'phase-list',
|
|
||||||
taskId: 'MQ-MGET-003',
|
|
||||||
title: 'Task three',
|
|
||||||
});
|
|
||||||
|
|
||||||
const tasks = await repository.list();
|
|
||||||
|
|
||||||
expect(tasks).toHaveLength(3);
|
|
||||||
expect(client.mgetCalls).toBe(1);
|
|
||||||
expect(client.getCalls).toBe(0);
|
|
||||||
expect(client.lastMgetKeys).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates mutable fields and preserves immutable fields', async () => {
|
it('updates mutable fields and preserves immutable fields', async () => {
|
||||||
const repository = new RedisTaskRepository({
|
const repository = new RedisTaskRepository({
|
||||||
client: new InMemoryRedisClient(),
|
client: new InMemoryRedisClient(),
|
||||||
@@ -310,23 +195,4 @@ describe('RedisTaskRepository CRUD', () => {
|
|||||||
expect(updated.taskId).toBe('MQ-003');
|
expect(updated.taskId).toBe('MQ-003');
|
||||||
expect(updated.updatedAt).toBe(1_700_000_000_001);
|
expect(updated.updatedAt).toBe(1_700_000_000_001);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects status transitions through update()', async () => {
|
|
||||||
const repository = new RedisTaskRepository({
|
|
||||||
client: new InMemoryRedisClient(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await repository.create({
|
|
||||||
project: 'queue',
|
|
||||||
mission: 'phase1',
|
|
||||||
taskId: 'MQ-003-TRANSITION',
|
|
||||||
title: 'Transition guard',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
repository.update('MQ-003-TRANSITION', {
|
|
||||||
status: 'completed',
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(TaskTransitionError);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user