@sprqvntrs/workflows 0.2.4
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/LICENSE +21 -0
- package/README.md +649 -0
- package/index.ts +234 -0
- package/package.json +53 -0
- package/src/coordination/lock-manager.ts +431 -0
- package/src/engine/execution-engine.ts +715 -0
- package/src/infrastructure/db-state.ts +703 -0
- package/src/infrastructure/pg-boss.ts +585 -0
- package/src/infrastructure/pgboss-queries.ts +320 -0
- package/src/infrastructure/schema.ts +342 -0
- package/src/operations/registry.ts +409 -0
- package/src/orchestrator.ts +1056 -0
- package/src/templates/registry.ts +591 -0
- package/src/testing/index.ts +476 -0
- package/src/types.ts +946 -0
- package/src/worker/index.ts +325 -0
package/index.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sprqvntrs/workflows
|
|
3
|
+
*
|
|
4
|
+
* PostgreSQL-backed workflow orchestration with pg-boss job queuing.
|
|
5
|
+
*
|
|
6
|
+
* This package provides a declarative, template-driven approach to executing
|
|
7
|
+
* multi-stage background workflows with reliable persistence and job processing.
|
|
8
|
+
*
|
|
9
|
+
* ## Quick Start
|
|
10
|
+
*
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { createWorkflowOrchestrator } from '@sprqvntrs/workflows';
|
|
13
|
+
* import { drizzle } from 'drizzle-orm/node-postgres';
|
|
14
|
+
* import { Pool } from 'pg';
|
|
15
|
+
*
|
|
16
|
+
* // Set up database
|
|
17
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
18
|
+
* const db = drizzle(pool);
|
|
19
|
+
*
|
|
20
|
+
* // Create orchestrator
|
|
21
|
+
* const orchestrator = await createWorkflowOrchestrator({
|
|
22
|
+
* connectionString: process.env.DATABASE_URL,
|
|
23
|
+
* db,
|
|
24
|
+
* queues: [
|
|
25
|
+
* { name: 'default', workers: 5 },
|
|
26
|
+
* { name: 'sequential', workers: 1 },
|
|
27
|
+
* ],
|
|
28
|
+
* });
|
|
29
|
+
*
|
|
30
|
+
* // Register a workflow template
|
|
31
|
+
* orchestrator.registerTemplate({
|
|
32
|
+
* type: 'content-generation',
|
|
33
|
+
* queue: 'default',
|
|
34
|
+
* version: '1.0.0',
|
|
35
|
+
* stages: [
|
|
36
|
+
* { name: 'gather', operations: [{ type: 'gather.data' }] },
|
|
37
|
+
* { name: 'analyze', parallel: true, operations: [
|
|
38
|
+
* { type: 'analyze.content' },
|
|
39
|
+
* { type: 'analyze.market' },
|
|
40
|
+
* ]},
|
|
41
|
+
* { name: 'generate', operations: [{ type: 'generate.report' }] },
|
|
42
|
+
* ],
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* // Register operation handlers
|
|
46
|
+
* orchestrator.registerOperations({
|
|
47
|
+
* 'gather.data': async (ctx) => {
|
|
48
|
+
* const data = await fetchData(ctx.previousResults.url);
|
|
49
|
+
* return { status: 'completed', data: { fetchedData: data } };
|
|
50
|
+
* },
|
|
51
|
+
* // ... more handlers
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* // Start a workflow
|
|
55
|
+
* const { workflowId } = await orchestrator.start({
|
|
56
|
+
* type: 'content-generation',
|
|
57
|
+
* context: { url: 'https://example.com' },
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*
|
|
61
|
+
* ## Architecture
|
|
62
|
+
*
|
|
63
|
+
* The package follows a three-layer architecture:
|
|
64
|
+
*
|
|
65
|
+
* 1. **Orchestrator** - Public API for starting, managing, and querying workflows
|
|
66
|
+
* 2. **Execution Engine** - Interprets templates and executes stages/operations
|
|
67
|
+
* 3. **Infrastructure** - pg-boss job queue and Drizzle database persistence
|
|
68
|
+
*
|
|
69
|
+
* ## Features
|
|
70
|
+
*
|
|
71
|
+
* - **Declarative Templates** - Define workflows as configuration objects
|
|
72
|
+
* - **Parallel Execution** - Run operations concurrently within stages
|
|
73
|
+
* - **Checkpoints** - Pause workflows for manual intervention
|
|
74
|
+
* - **Workflow Chaining** - Trigger follow-up workflows on completion
|
|
75
|
+
* - **Fix-Verify Loops** - Automatic retry cycles for data integrity
|
|
76
|
+
* - **Entity Locking** - Prevent race conditions between workflows
|
|
77
|
+
* - **Type Safety** - Full TypeScript support with generics
|
|
78
|
+
*
|
|
79
|
+
* @packageDocumentation
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// Main Orchestrator
|
|
84
|
+
// =============================================================================
|
|
85
|
+
|
|
86
|
+
export { createWorkflowOrchestrator, type WorkflowOrchestrator, type CreateOrchestratorConfig } from './src/orchestrator';
|
|
87
|
+
|
|
88
|
+
// =============================================================================
|
|
89
|
+
// Core Types
|
|
90
|
+
// =============================================================================
|
|
91
|
+
|
|
92
|
+
export type {
|
|
93
|
+
// Status types
|
|
94
|
+
WorkflowStatus,
|
|
95
|
+
OperationStatus,
|
|
96
|
+
|
|
97
|
+
// Template types
|
|
98
|
+
WorkflowTemplate,
|
|
99
|
+
StageTemplate,
|
|
100
|
+
OperationTemplate,
|
|
101
|
+
CheckpointTemplate,
|
|
102
|
+
CoordinationConfig,
|
|
103
|
+
QueueConfig,
|
|
104
|
+
|
|
105
|
+
// Operation types
|
|
106
|
+
OperationHandler,
|
|
107
|
+
OperationContext,
|
|
108
|
+
OperationResult,
|
|
109
|
+
|
|
110
|
+
// Record types
|
|
111
|
+
WorkflowRecord,
|
|
112
|
+
OperationRecord,
|
|
113
|
+
WorkflowContext,
|
|
114
|
+
|
|
115
|
+
// Configuration types
|
|
116
|
+
OrchestratorConfig,
|
|
117
|
+
QueueDefinition,
|
|
118
|
+
|
|
119
|
+
// API types
|
|
120
|
+
StartWorkflowOptions,
|
|
121
|
+
StartWorkflowResult,
|
|
122
|
+
ScheduleOptions,
|
|
123
|
+
WorkflowStatusDetails,
|
|
124
|
+
} from './src/types';
|
|
125
|
+
|
|
126
|
+
// Error types
|
|
127
|
+
export { WorkflowError, TemplateError, OperationError, TimeoutError, CoordinationError } from './src/types';
|
|
128
|
+
|
|
129
|
+
// =============================================================================
|
|
130
|
+
// Registries
|
|
131
|
+
// =============================================================================
|
|
132
|
+
|
|
133
|
+
export {
|
|
134
|
+
createTemplateRegistry,
|
|
135
|
+
validateTemplate,
|
|
136
|
+
getOperationTypes,
|
|
137
|
+
getQueueNames,
|
|
138
|
+
type TemplateRegistry,
|
|
139
|
+
type ValidationError,
|
|
140
|
+
} from './src/templates/registry';
|
|
141
|
+
|
|
142
|
+
export {
|
|
143
|
+
createOperationRegistry,
|
|
144
|
+
withTimeout,
|
|
145
|
+
withRetry,
|
|
146
|
+
withErrorBoundary,
|
|
147
|
+
compose,
|
|
148
|
+
defineHandler,
|
|
149
|
+
type OperationRegistry,
|
|
150
|
+
type TypedOperationHandler,
|
|
151
|
+
} from './src/operations/registry';
|
|
152
|
+
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// Execution Engine
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
157
|
+
export {
|
|
158
|
+
createExecutionEngine,
|
|
159
|
+
type ExecutionEngine,
|
|
160
|
+
type ExecutionEngineConfig,
|
|
161
|
+
} from './src/engine/execution-engine';
|
|
162
|
+
|
|
163
|
+
// =============================================================================
|
|
164
|
+
// Infrastructure
|
|
165
|
+
// =============================================================================
|
|
166
|
+
|
|
167
|
+
// pg-boss utilities
|
|
168
|
+
export {
|
|
169
|
+
createBoss,
|
|
170
|
+
createSendOptions,
|
|
171
|
+
createWorkOptions,
|
|
172
|
+
registerWorker,
|
|
173
|
+
registerBatchWorker,
|
|
174
|
+
scheduleRecurring,
|
|
175
|
+
unschedule,
|
|
176
|
+
getJob,
|
|
177
|
+
cancelJob,
|
|
178
|
+
resumeJob,
|
|
179
|
+
setupGracefulShutdown,
|
|
180
|
+
setupEventListeners,
|
|
181
|
+
DEFAULT_QUEUE_CONFIG,
|
|
182
|
+
DEFAULT_PGBOSS_CONFIG,
|
|
183
|
+
type PgBossConfig,
|
|
184
|
+
type WorkflowJobData,
|
|
185
|
+
type JobHandler,
|
|
186
|
+
type BatchJobHandler,
|
|
187
|
+
type PgBossEvent,
|
|
188
|
+
} from './src/infrastructure/pg-boss';
|
|
189
|
+
|
|
190
|
+
// Database state
|
|
191
|
+
export {
|
|
192
|
+
createDbState,
|
|
193
|
+
type DbState,
|
|
194
|
+
type Database,
|
|
195
|
+
type CreateWorkflowData,
|
|
196
|
+
type CreateOperationData,
|
|
197
|
+
type ListWorkflowsOptions,
|
|
198
|
+
} from './src/infrastructure/db-state';
|
|
199
|
+
|
|
200
|
+
// pg-boss introspection queries
|
|
201
|
+
export {
|
|
202
|
+
createPgBossQueries,
|
|
203
|
+
type PgBossQueries,
|
|
204
|
+
type PgBossQueriesConfig,
|
|
205
|
+
type PgBossJobState,
|
|
206
|
+
type PgBossArchiveState,
|
|
207
|
+
type PgBossJobItem,
|
|
208
|
+
type PgBossJobStats,
|
|
209
|
+
type PgBossScheduleItem,
|
|
210
|
+
type PgBossArchiveItem,
|
|
211
|
+
type DeleteWorkflowJobsResult,
|
|
212
|
+
type PendingJobsResult,
|
|
213
|
+
} from './src/infrastructure/pgboss-queries';
|
|
214
|
+
|
|
215
|
+
// =============================================================================
|
|
216
|
+
// Coordination
|
|
217
|
+
// =============================================================================
|
|
218
|
+
|
|
219
|
+
export {
|
|
220
|
+
createLockManager,
|
|
221
|
+
withLock,
|
|
222
|
+
withLockWait,
|
|
223
|
+
type LockManager,
|
|
224
|
+
type LockOptions,
|
|
225
|
+
type WaitOptions,
|
|
226
|
+
type LockInfo,
|
|
227
|
+
} from './src/coordination/lock-manager';
|
|
228
|
+
|
|
229
|
+
// =============================================================================
|
|
230
|
+
// Re-exports for convenience
|
|
231
|
+
// =============================================================================
|
|
232
|
+
|
|
233
|
+
// Re-export PgBoss for advanced usage
|
|
234
|
+
export { default as PgBoss } from 'pg-boss';
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sprqvntrs/workflows",
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"description": "PostgreSQL-backed workflow orchestration with pg-boss job queuing",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"types": "./index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.ts",
|
|
10
|
+
"./worker": "./src/worker/index.ts",
|
|
11
|
+
"./schema": "./src/infrastructure/schema.ts",
|
|
12
|
+
"./testing": "./src/testing/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/SPRQVNTRS/platform.git",
|
|
18
|
+
"directory": "packages/workflows"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/**/*",
|
|
22
|
+
"index.ts",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"pg-boss": "^10.1.5"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"drizzle-orm": "^0.39.0",
|
|
35
|
+
"pg": "^8.11.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^22.0.0",
|
|
39
|
+
"@types/pg": "^8.11.0",
|
|
40
|
+
"drizzle-orm": "^0.39.0",
|
|
41
|
+
"pg": "^8.11.0",
|
|
42
|
+
"tsx": "^4.20.6",
|
|
43
|
+
"typescript": "^5.6.0",
|
|
44
|
+
"vitest": "^3.2.4"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/SPRQVNTRS/platform/tree/main/packages/workflows#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/SPRQVNTRS/platform/issues"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock manager for workflow coordination.
|
|
3
|
+
*
|
|
4
|
+
* This module provides entity locking to prevent race conditions when
|
|
5
|
+
* multiple workflows operate on the same entity.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createLockManager } from '@sprqvntrs/workflows';
|
|
10
|
+
*
|
|
11
|
+
* const lockManager = createLockManager(dbState);
|
|
12
|
+
*
|
|
13
|
+
* // Acquire lock before processing
|
|
14
|
+
* const acquired = await lockManager.acquire('document', '123', workflowId);
|
|
15
|
+
* if (!acquired) {
|
|
16
|
+
* throw new Error('Entity is locked by another workflow');
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* try {
|
|
20
|
+
* // Process entity...
|
|
21
|
+
* } finally {
|
|
22
|
+
* await lockManager.release('document', '123');
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { DbState } from '../infrastructure/db-state';
|
|
28
|
+
import { CoordinationError } from '../types';
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Types
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Lock manager interface.
|
|
36
|
+
*/
|
|
37
|
+
export interface LockManager {
|
|
38
|
+
/**
|
|
39
|
+
* Acquires a lock on an entity.
|
|
40
|
+
*
|
|
41
|
+
* @param entityType - Type of entity (e.g., 'document', 'user')
|
|
42
|
+
* @param entityId - Entity identifier
|
|
43
|
+
* @param workflowId - Workflow requesting the lock
|
|
44
|
+
* @param options - Lock options
|
|
45
|
+
* @returns True if lock was acquired
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const acquired = await lockManager.acquire('document', '123', workflowId, {
|
|
50
|
+
* ttlMs: 600000, // 10 minute TTL
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
acquire: (
|
|
55
|
+
entityType: string,
|
|
56
|
+
entityId: string,
|
|
57
|
+
workflowId: string,
|
|
58
|
+
options?: LockOptions,
|
|
59
|
+
) => Promise<boolean>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Releases a lock on an entity.
|
|
63
|
+
*
|
|
64
|
+
* @param entityType - Type of entity
|
|
65
|
+
* @param entityId - Entity identifier
|
|
66
|
+
* @returns True if lock was released
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* await lockManager.release('document', '123');
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
release: (entityType: string, entityId: string) => Promise<boolean>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Releases all locks held by a workflow.
|
|
77
|
+
*
|
|
78
|
+
* @param workflowId - Workflow ID
|
|
79
|
+
* @returns Number of locks released
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* const count = await lockManager.releaseAll(workflowId);
|
|
84
|
+
* console.log(`Released ${count} locks`);
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
releaseAll: (workflowId: string) => Promise<number>;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Checks if an entity is locked.
|
|
91
|
+
*
|
|
92
|
+
* @param entityType - Type of entity
|
|
93
|
+
* @param entityId - Entity identifier
|
|
94
|
+
* @returns Lock info or null if not locked
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* const lock = await lockManager.check('document', '123');
|
|
99
|
+
* if (lock) {
|
|
100
|
+
* console.log(`Locked by workflow ${lock.workflowId}`);
|
|
101
|
+
* }
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
check: (entityType: string, entityId: string) => Promise<LockInfo | null>;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Waits for a lock to become available.
|
|
108
|
+
*
|
|
109
|
+
* @param entityType - Type of entity
|
|
110
|
+
* @param entityId - Entity identifier
|
|
111
|
+
* @param workflowId - Workflow requesting the lock
|
|
112
|
+
* @param options - Wait options
|
|
113
|
+
* @returns True if lock was acquired
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```typescript
|
|
117
|
+
* const acquired = await lockManager.waitAndAcquire('document', '123', workflowId, {
|
|
118
|
+
* timeoutMs: 30000,
|
|
119
|
+
* pollIntervalMs: 1000,
|
|
120
|
+
* });
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
waitAndAcquire: (
|
|
124
|
+
entityType: string,
|
|
125
|
+
entityId: string,
|
|
126
|
+
workflowId: string,
|
|
127
|
+
options?: WaitOptions,
|
|
128
|
+
) => Promise<boolean>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Cleans up expired locks.
|
|
132
|
+
*
|
|
133
|
+
* Should be called periodically to release stale locks.
|
|
134
|
+
*
|
|
135
|
+
* @returns Number of locks cleaned up
|
|
136
|
+
*/
|
|
137
|
+
cleanup: () => Promise<number>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Options for acquiring a lock.
|
|
142
|
+
*/
|
|
143
|
+
export interface LockOptions {
|
|
144
|
+
/**
|
|
145
|
+
* Time-to-live for the lock in milliseconds.
|
|
146
|
+
* If not provided, lock has no expiration.
|
|
147
|
+
*/
|
|
148
|
+
ttlMs?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Options for waiting for a lock.
|
|
153
|
+
*/
|
|
154
|
+
export interface WaitOptions extends LockOptions {
|
|
155
|
+
/**
|
|
156
|
+
* Maximum time to wait in milliseconds.
|
|
157
|
+
* @default 60000 (1 minute)
|
|
158
|
+
*/
|
|
159
|
+
timeoutMs?: number;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Interval between lock checks in milliseconds.
|
|
163
|
+
* @default 1000 (1 second)
|
|
164
|
+
*/
|
|
165
|
+
pollIntervalMs?: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Information about a held lock.
|
|
170
|
+
*/
|
|
171
|
+
export interface LockInfo {
|
|
172
|
+
/**
|
|
173
|
+
* Type of locked entity.
|
|
174
|
+
*/
|
|
175
|
+
entityType: string;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* ID of locked entity.
|
|
179
|
+
*/
|
|
180
|
+
entityId: string;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Workflow holding the lock.
|
|
184
|
+
*/
|
|
185
|
+
workflowId: string;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* When lock was acquired.
|
|
189
|
+
*/
|
|
190
|
+
acquiredAt: Date;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* When lock expires (if TTL was set).
|
|
194
|
+
*/
|
|
195
|
+
expiresAt: Date | null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// =============================================================================
|
|
199
|
+
// Factory Function
|
|
200
|
+
// =============================================================================
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Creates a lock manager instance.
|
|
204
|
+
*
|
|
205
|
+
* The lock manager provides entity-level locking to prevent race conditions
|
|
206
|
+
* when multiple workflows operate on the same entity.
|
|
207
|
+
*
|
|
208
|
+
* @param dbState - Database state manager
|
|
209
|
+
* @returns Lock manager instance
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* ```typescript
|
|
213
|
+
* const lockManager = createLockManager(dbState);
|
|
214
|
+
*
|
|
215
|
+
* // In your workflow operation
|
|
216
|
+
* async function processDocument(context: OperationContext) {
|
|
217
|
+
* const { workflowId, previousResults } = context;
|
|
218
|
+
* const documentId = previousResults.documentId as string;
|
|
219
|
+
*
|
|
220
|
+
* // Acquire lock with TTL
|
|
221
|
+
* const acquired = await lockManager.acquire('document', documentId, workflowId, {
|
|
222
|
+
* ttlMs: 600000, // 10 minutes
|
|
223
|
+
* });
|
|
224
|
+
*
|
|
225
|
+
* if (!acquired) {
|
|
226
|
+
* return { status: 'failed', reason: 'Document is being processed by another workflow' };
|
|
227
|
+
* }
|
|
228
|
+
*
|
|
229
|
+
* try {
|
|
230
|
+
* // Process document...
|
|
231
|
+
* return { status: 'completed', data: { processed: true } };
|
|
232
|
+
* } finally {
|
|
233
|
+
* // Always release in finally block
|
|
234
|
+
* await lockManager.release('document', documentId);
|
|
235
|
+
* }
|
|
236
|
+
* }
|
|
237
|
+
* ```
|
|
238
|
+
*/
|
|
239
|
+
export function createLockManager(dbState: DbState): LockManager {
|
|
240
|
+
return {
|
|
241
|
+
async acquire(
|
|
242
|
+
entityType: string,
|
|
243
|
+
entityId: string,
|
|
244
|
+
workflowId: string,
|
|
245
|
+
options?: LockOptions,
|
|
246
|
+
): Promise<boolean> {
|
|
247
|
+
const expiresAt = options?.ttlMs ? new Date(Date.now() + options.ttlMs) : undefined;
|
|
248
|
+
|
|
249
|
+
return dbState.acquireLock(entityType, entityId, workflowId, expiresAt);
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async release(entityType: string, entityId: string): Promise<boolean> {
|
|
253
|
+
return dbState.releaseLock(entityType, entityId);
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async releaseAll(workflowId: string): Promise<number> {
|
|
257
|
+
return dbState.releaseLocksByWorkflow(workflowId);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async check(entityType: string, entityId: string): Promise<LockInfo | null> {
|
|
261
|
+
const lock = await dbState.getLock(entityType, entityId);
|
|
262
|
+
if (!lock) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
entityType: lock.entityType,
|
|
268
|
+
entityId: lock.entityId,
|
|
269
|
+
workflowId: lock.workflowId,
|
|
270
|
+
acquiredAt: lock.acquiredAt,
|
|
271
|
+
expiresAt: lock.expiresAt,
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
async waitAndAcquire(
|
|
276
|
+
entityType: string,
|
|
277
|
+
entityId: string,
|
|
278
|
+
workflowId: string,
|
|
279
|
+
options?: WaitOptions,
|
|
280
|
+
): Promise<boolean> {
|
|
281
|
+
const { timeoutMs = 60000, pollIntervalMs = 1000, ttlMs } = options ?? {};
|
|
282
|
+
const deadline = Date.now() + timeoutMs;
|
|
283
|
+
|
|
284
|
+
while (Date.now() < deadline) {
|
|
285
|
+
// Try to acquire
|
|
286
|
+
const expiresAt = ttlMs ? new Date(Date.now() + ttlMs) : undefined;
|
|
287
|
+
const acquired = await dbState.acquireLock(entityType, entityId, workflowId, expiresAt);
|
|
288
|
+
|
|
289
|
+
if (acquired) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Check if existing lock has expired
|
|
294
|
+
const existingLock = await dbState.getLock(entityType, entityId);
|
|
295
|
+
if (existingLock?.expiresAt && existingLock.expiresAt < new Date()) {
|
|
296
|
+
// Lock has expired, clean it up and try again
|
|
297
|
+
await dbState.releaseLock(entityType, entityId);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Wait before next attempt
|
|
302
|
+
if (Date.now() + pollIntervalMs < deadline) {
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
304
|
+
} else {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return false;
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async cleanup(): Promise<number> {
|
|
313
|
+
return dbState.cleanupExpiredLocks();
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// =============================================================================
|
|
319
|
+
// Helper Functions
|
|
320
|
+
// =============================================================================
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Executes a function with a lock, automatically releasing on completion.
|
|
324
|
+
*
|
|
325
|
+
* This helper ensures the lock is always released, even if the function throws.
|
|
326
|
+
*
|
|
327
|
+
* @param lockManager - Lock manager instance
|
|
328
|
+
* @param entityType - Type of entity to lock
|
|
329
|
+
* @param entityId - Entity identifier
|
|
330
|
+
* @param workflowId - Workflow ID
|
|
331
|
+
* @param fn - Function to execute while holding the lock
|
|
332
|
+
* @param options - Lock options
|
|
333
|
+
* @returns Result of the function
|
|
334
|
+
* @throws CoordinationError if lock cannot be acquired
|
|
335
|
+
*
|
|
336
|
+
* @example
|
|
337
|
+
* ```typescript
|
|
338
|
+
* const result = await withLock(
|
|
339
|
+
* lockManager,
|
|
340
|
+
* 'document',
|
|
341
|
+
* '123',
|
|
342
|
+
* workflowId,
|
|
343
|
+
* async () => {
|
|
344
|
+
* // This runs while holding the lock
|
|
345
|
+
* return await processDocument();
|
|
346
|
+
* },
|
|
347
|
+
* { ttlMs: 300000 }
|
|
348
|
+
* );
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
export async function withLock<T>(
|
|
352
|
+
lockManager: LockManager,
|
|
353
|
+
entityType: string,
|
|
354
|
+
entityId: string,
|
|
355
|
+
workflowId: string,
|
|
356
|
+
fn: () => Promise<T>,
|
|
357
|
+
options?: LockOptions,
|
|
358
|
+
): Promise<T> {
|
|
359
|
+
const acquired = await lockManager.acquire(entityType, entityId, workflowId, options);
|
|
360
|
+
|
|
361
|
+
if (!acquired) {
|
|
362
|
+
throw new CoordinationError(`Failed to acquire lock on ${entityType}:${entityId}`, {
|
|
363
|
+
entityType,
|
|
364
|
+
entityId,
|
|
365
|
+
workflowId,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
return await fn();
|
|
371
|
+
} finally {
|
|
372
|
+
await lockManager.release(entityType, entityId);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Executes a function with a lock, waiting if necessary.
|
|
378
|
+
*
|
|
379
|
+
* Like `withLock`, but waits for the lock to become available.
|
|
380
|
+
*
|
|
381
|
+
* @param lockManager - Lock manager instance
|
|
382
|
+
* @param entityType - Type of entity to lock
|
|
383
|
+
* @param entityId - Entity identifier
|
|
384
|
+
* @param workflowId - Workflow ID
|
|
385
|
+
* @param fn - Function to execute while holding the lock
|
|
386
|
+
* @param options - Wait options
|
|
387
|
+
* @returns Result of the function
|
|
388
|
+
* @throws CoordinationError if lock cannot be acquired within timeout
|
|
389
|
+
*
|
|
390
|
+
* @example
|
|
391
|
+
* ```typescript
|
|
392
|
+
* const result = await withLockWait(
|
|
393
|
+
* lockManager,
|
|
394
|
+
* 'document',
|
|
395
|
+
* '123',
|
|
396
|
+
* workflowId,
|
|
397
|
+
* async () => {
|
|
398
|
+
* return await processDocument();
|
|
399
|
+
* },
|
|
400
|
+
* { timeoutMs: 60000, ttlMs: 300000 }
|
|
401
|
+
* );
|
|
402
|
+
* ```
|
|
403
|
+
*/
|
|
404
|
+
export async function withLockWait<T>(
|
|
405
|
+
lockManager: LockManager,
|
|
406
|
+
entityType: string,
|
|
407
|
+
entityId: string,
|
|
408
|
+
workflowId: string,
|
|
409
|
+
fn: () => Promise<T>,
|
|
410
|
+
options?: WaitOptions,
|
|
411
|
+
): Promise<T> {
|
|
412
|
+
const acquired = await lockManager.waitAndAcquire(entityType, entityId, workflowId, options);
|
|
413
|
+
|
|
414
|
+
if (!acquired) {
|
|
415
|
+
throw new CoordinationError(
|
|
416
|
+
`Timed out waiting for lock on ${entityType}:${entityId}`,
|
|
417
|
+
{
|
|
418
|
+
entityType,
|
|
419
|
+
entityId,
|
|
420
|
+
workflowId,
|
|
421
|
+
timeoutMs: options?.timeoutMs,
|
|
422
|
+
},
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
return await fn();
|
|
428
|
+
} finally {
|
|
429
|
+
await lockManager.release(entityType, entityId);
|
|
430
|
+
}
|
|
431
|
+
}
|