depa-actor 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.
@@ -0,0 +1,337 @@
1
+ import type { MailboxSchema } from '../core/types';
2
+ import { applyFailure } from './recovery';
3
+ import {
4
+ DEFAULT_ORCHESTRATOR_OPTIONS,
5
+ type FiberAction,
6
+ type FiberId,
7
+ type FiberRecord,
8
+ type OrchestratorOptions,
9
+ type OrchestratorState,
10
+ type ReduceResult,
11
+ type SpawnFiberInput,
12
+ type SuspendPolicy,
13
+ } from './types';
14
+
15
+ function isTerminalStatus(status: FiberRecord<MailboxSchema>['status']): boolean {
16
+ return status === 'completed' || status === 'cancelled' || status === 'failed' || status === 'dead_letter';
17
+ }
18
+
19
+ function cloneFiberMap<TSchema extends MailboxSchema>(
20
+ state: OrchestratorState<TSchema>,
21
+ ): Record<FiberId, FiberRecord<TSchema>> {
22
+ return { ...state.fibers };
23
+ }
24
+
25
+ function upsertParentChildRelation<TSchema extends MailboxSchema>(
26
+ fibers: Record<FiberId, FiberRecord<TSchema>>,
27
+ fiber: SpawnFiberInput<TSchema>,
28
+ now: number,
29
+ ): void {
30
+ if (!fiber.parentId) {
31
+ return;
32
+ }
33
+ const parent = fibers[fiber.parentId];
34
+ if (!parent) {
35
+ return;
36
+ }
37
+
38
+ if (!parent.childIds.includes(fiber.id)) {
39
+ fibers[fiber.parentId] = {
40
+ ...parent,
41
+ childIds: [...parent.childIds, fiber.id],
42
+ updatedAt: now,
43
+ };
44
+ }
45
+ }
46
+
47
+ function createFiberRecord<TSchema extends MailboxSchema>(
48
+ input: SpawnFiberInput<TSchema>,
49
+ now: number,
50
+ order: number,
51
+ state: OrchestratorState<TSchema>,
52
+ ): FiberRecord<TSchema> {
53
+ const timeoutMs = input.timeoutMs ?? state.options.defaultTimeoutMs;
54
+ return {
55
+ id: input.id,
56
+ actorId: input.actorId,
57
+ parentId: input.parentId,
58
+ childIds: [],
59
+ lane: input.lane ?? 'default',
60
+ status: 'ready',
61
+ basePriority: input.basePriority,
62
+ age: 0,
63
+ attempts: 0,
64
+ maxAttempts: Math.max(0, input.maxAttempts ?? 0),
65
+ step: input.step,
66
+ waitingReason: undefined,
67
+ suspendPolicy: undefined,
68
+ timeoutMs: timeoutMs > 0 ? timeoutMs : undefined,
69
+ timeoutAt: undefined,
70
+ retryAt: undefined,
71
+ lastError: undefined,
72
+ order,
73
+ createdAt: now,
74
+ updatedAt: now,
75
+ };
76
+ }
77
+
78
+ export function createOrchestratorState<TSchema extends MailboxSchema>(
79
+ options?: Partial<OrchestratorOptions<TSchema>>,
80
+ ): OrchestratorState<TSchema> {
81
+ const raw = (options ?? {}) as Partial<OrchestratorOptions<TSchema>>;
82
+ const effectiveDefault: SuspendPolicy =
83
+ (raw.defaultSuspendPolicy as SuspendPolicy | undefined) ??
84
+ (DEFAULT_ORCHESTRATOR_OPTIONS.defaultSuspendPolicy as SuspendPolicy);
85
+
86
+ const base = {
87
+ ...DEFAULT_ORCHESTRATOR_OPTIONS,
88
+ ...raw,
89
+ } as OrchestratorOptions<TSchema>;
90
+
91
+ return {
92
+ options: {
93
+ ...base,
94
+ defaultSuspendPolicy: effectiveDefault,
95
+ },
96
+ fibers: {},
97
+ deadLetters: [],
98
+ sequence: 0,
99
+ };
100
+ }
101
+
102
+ function applyTick<TSchema extends MailboxSchema>(
103
+ state: OrchestratorState<TSchema>,
104
+ now: number,
105
+ ): ReduceResult<TSchema> {
106
+ let nextState = state;
107
+ let aggregatedEffects: ReduceResult<TSchema>['effects'] = [];
108
+
109
+ const ids = Object.keys(state.fibers);
110
+ for (const id of ids) {
111
+ const fiber = nextState.fibers[id];
112
+ if (!fiber) {
113
+ continue;
114
+ }
115
+ if (isTerminalStatus(fiber.status)) {
116
+ continue;
117
+ }
118
+
119
+ if (
120
+ nextState.options.timeoutEnabled &&
121
+ fiber.timeoutAt !== undefined &&
122
+ now >= fiber.timeoutAt &&
123
+ (fiber.status === 'running' || fiber.status === 'ready' || fiber.status === 'suspended')
124
+ ) {
125
+ const timed = applyFailure(nextState, id, now, 'timeout');
126
+ nextState = timed.state;
127
+ aggregatedEffects = [...aggregatedEffects, ...timed.effects];
128
+ continue;
129
+ }
130
+
131
+ const iter = nextState.fibers[id];
132
+ if (
133
+ iter &&
134
+ iter.status === 'suspended' &&
135
+ iter.waitingReason === 'retry_backoff' &&
136
+ iter.retryAt !== undefined &&
137
+ now >= iter.retryAt
138
+ ) {
139
+ nextState = {
140
+ ...nextState,
141
+ fibers: {
142
+ ...nextState.fibers,
143
+ [id]: {
144
+ ...iter,
145
+ status: 'ready',
146
+ waitingReason: undefined,
147
+ retryAt: undefined,
148
+ timeoutAt: undefined,
149
+ updatedAt: now,
150
+ },
151
+ },
152
+ };
153
+ }
154
+ }
155
+
156
+ return {
157
+ state: nextState,
158
+ effects: aggregatedEffects,
159
+ };
160
+ }
161
+
162
+ export function reduceOrchestrator<TSchema extends MailboxSchema>(
163
+ state: OrchestratorState<TSchema>,
164
+ action: FiberAction<TSchema>,
165
+ ): ReduceResult<TSchema> {
166
+ if (action.type === 'tick') {
167
+ return applyTick(state, action.now);
168
+ }
169
+
170
+ if (action.type === 'spawn') {
171
+ const nextSequence = state.sequence + 1;
172
+ const nextFibers = cloneFiberMap(state);
173
+ const record = createFiberRecord(action.fiber, action.now, nextSequence, state);
174
+ nextFibers[action.fiber.id] = record;
175
+ upsertParentChildRelation(nextFibers, action.fiber, action.now);
176
+ return {
177
+ state: {
178
+ ...state,
179
+ sequence: nextSequence,
180
+ fibers: nextFibers,
181
+ },
182
+ effects: [],
183
+ };
184
+ }
185
+
186
+ const current = state.fibers[action.fiberId];
187
+ if (!current) {
188
+ return { state, effects: [] };
189
+ }
190
+
191
+ if (action.type === 'yield') {
192
+ if (isTerminalStatus(current.status)) {
193
+ return { state, effects: [] };
194
+ }
195
+ return {
196
+ state: {
197
+ ...state,
198
+ fibers: {
199
+ ...state.fibers,
200
+ [action.fiberId]: {
201
+ ...current,
202
+ status: 'ready',
203
+ waitingReason: undefined,
204
+ suspendPolicy: undefined,
205
+ timeoutAt: undefined,
206
+ step: action.nextStep ?? current.step,
207
+ updatedAt: action.now,
208
+ },
209
+ },
210
+ },
211
+ effects: [],
212
+ };
213
+ }
214
+
215
+ if (action.type === 'suspend') {
216
+ if (isTerminalStatus(current.status)) {
217
+ return { state, effects: [] };
218
+ }
219
+
220
+ const effectivePolicy: SuspendPolicy =
221
+ (action.suspendPolicy as SuspendPolicy | undefined) ??
222
+ (state.options.defaultSuspendPolicy as SuspendPolicy | undefined) ??
223
+ 'continue_others';
224
+ return {
225
+ state: {
226
+ ...state,
227
+ fibers: {
228
+ ...state.fibers,
229
+ [action.fiberId]: {
230
+ ...current,
231
+ status: 'suspended',
232
+ waitingReason: action.reason,
233
+ suspendPolicy: effectivePolicy,
234
+ timeoutAt: undefined,
235
+ updatedAt: action.now,
236
+ },
237
+ },
238
+ },
239
+ effects: [],
240
+ };
241
+ }
242
+
243
+ if (action.type === 'resume') {
244
+ if (isTerminalStatus(current.status)) {
245
+ return { state, effects: [] };
246
+ }
247
+ return {
248
+ state: {
249
+ ...state,
250
+ fibers: {
251
+ ...state.fibers,
252
+ [action.fiberId]: {
253
+ ...current,
254
+ status: 'ready',
255
+ waitingReason: undefined,
256
+ suspendPolicy: undefined,
257
+ retryAt: undefined,
258
+ timeoutAt: undefined,
259
+ step: action.nextStep ?? current.step,
260
+ updatedAt: action.now,
261
+ },
262
+ },
263
+ },
264
+ effects: [],
265
+ };
266
+ }
267
+
268
+ if (action.type === 'complete') {
269
+ return {
270
+ state: {
271
+ ...state,
272
+ fibers: {
273
+ ...state.fibers,
274
+ [action.fiberId]: {
275
+ ...current,
276
+ status: 'completed',
277
+ waitingReason: undefined,
278
+ suspendPolicy: undefined,
279
+ timeoutAt: undefined,
280
+ updatedAt: action.now,
281
+ },
282
+ },
283
+ },
284
+ effects: [],
285
+ };
286
+ }
287
+
288
+ if (action.type === 'fail') {
289
+ return applyFailure(state, action.fiberId, action.now, action.error);
290
+ }
291
+
292
+ if (action.type === 'cancel') {
293
+ const nextFibers = cloneFiberMap(state);
294
+ const queue = [action.fiberId];
295
+ const seen = new Set<string>();
296
+
297
+ while (queue.length > 0) {
298
+ const id = queue.shift()!;
299
+ if (seen.has(id)) {
300
+ continue;
301
+ }
302
+ seen.add(id);
303
+
304
+ const item = nextFibers[id];
305
+ if (!item) {
306
+ continue;
307
+ }
308
+
309
+ nextFibers[id] = {
310
+ ...item,
311
+ status: 'cancelled',
312
+ waitingReason: undefined,
313
+ suspendPolicy: undefined,
314
+ timeoutAt: undefined,
315
+ retryAt: undefined,
316
+ lastError: action.reason,
317
+ updatedAt: action.now,
318
+ };
319
+
320
+ if (action.propagateToChildren) {
321
+ for (const childId of item.childIds) {
322
+ queue.push(childId);
323
+ }
324
+ }
325
+ }
326
+
327
+ return {
328
+ state: {
329
+ ...state,
330
+ fibers: nextFibers,
331
+ },
332
+ effects: [],
333
+ };
334
+ }
335
+
336
+ return { state, effects: [] };
337
+ }
@@ -0,0 +1,19 @@
1
+ import type { MailboxSchema } from '../core/types';
2
+ import type { ActorRuntime } from '../runtime/ActorRuntime';
3
+ import type { FiberEffect } from './types';
4
+
5
+ export function dispatchEffects<TRuntime, TSchema extends MailboxSchema>(
6
+ runtime: ActorRuntime<TRuntime, TSchema>,
7
+ effects: FiberEffect<TSchema>[],
8
+ senderId?: string,
9
+ ): void {
10
+ const from = senderId ?? '__fiber_scheduler__';
11
+ for (const effect of effects) {
12
+ if (effect.kind === 'send') {
13
+ runtime.sendFrom(from, effect.to, effect.step.tag, effect.step.payload);
14
+ }
15
+ if (effect.kind === 'dead_letter' && effect.to && effect.step) {
16
+ runtime.sendFrom(from, effect.to, effect.step.tag, effect.step.payload);
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,111 @@
1
+ import type { MailboxSchema } from '../core/types';
2
+ import type { FiberEffect, FiberId, FiberRecord, OrchestratorState } from './types';
3
+
4
+ export interface ScheduleResult<TSchema extends MailboxSchema> {
5
+ state: OrchestratorState<TSchema>;
6
+ effects: FiberEffect<TSchema>[];
7
+ selectedFiberId?: FiberId;
8
+ }
9
+
10
+ function isSchedulable<TSchema extends MailboxSchema>(fiber: FiberRecord<TSchema>): boolean {
11
+ return fiber.status === 'ready';
12
+ }
13
+
14
+ export function computeEffectivePriority<TSchema extends MailboxSchema>(
15
+ fiber: FiberRecord<TSchema>,
16
+ agingStep: number,
17
+ ): number {
18
+ return fiber.basePriority - fiber.age * Math.max(0, agingStep);
19
+ }
20
+
21
+ export function selectNextFiberId<TSchema extends MailboxSchema>(
22
+ state: OrchestratorState<TSchema>,
23
+ now?: number,
24
+ ): FiberId | undefined {
25
+ const hooks = state.options.schedulerHooks;
26
+ const selectionNow = typeof now === 'number' ? now : Date.now();
27
+ let candidates = Object.values(state.fibers).filter(isSchedulable);
28
+
29
+ if (hooks?.filterCandidates) {
30
+ candidates = hooks.filterCandidates({ state, candidates, now: selectionNow });
31
+ } else if (hooks?.filterCandidate) {
32
+ const f = hooks.filterCandidate;
33
+ candidates = candidates.filter(fiber => f({ state, fiber, now: selectionNow }));
34
+ }
35
+
36
+ if (candidates.length === 0) {
37
+ return undefined;
38
+ }
39
+
40
+ const agingStep = state.options.agingStep;
41
+ candidates.sort((a, b) => {
42
+ const pa = computeEffectivePriority(a, agingStep);
43
+ const pb = computeEffectivePriority(b, agingStep);
44
+ if (pa !== pb) {
45
+ return pa - pb;
46
+ }
47
+ return a.order - b.order;
48
+ });
49
+
50
+ return candidates[0]?.id;
51
+ }
52
+
53
+ export function scheduleOne<TSchema extends MailboxSchema>(
54
+ state: OrchestratorState<TSchema>,
55
+ now: number,
56
+ ): ScheduleResult<TSchema> {
57
+ const selectedFiberId = selectNextFiberId(state, now);
58
+ if (!selectedFiberId) {
59
+ return { state, effects: [] };
60
+ }
61
+
62
+ const selected = state.fibers[selectedFiberId];
63
+ if (!selected) {
64
+ return { state, effects: [] };
65
+ }
66
+
67
+ const timeoutMs = selected.timeoutMs ?? state.options.defaultTimeoutMs;
68
+ const timeoutAt = state.options.timeoutEnabled && timeoutMs > 0 ? now + timeoutMs : undefined;
69
+
70
+ const nextFibers: Record<FiberId, FiberRecord<TSchema>> = { ...state.fibers };
71
+ for (const [id, fiber] of Object.entries(state.fibers)) {
72
+ if (id === selectedFiberId) {
73
+ nextFibers[id] = {
74
+ ...fiber,
75
+ status: 'running',
76
+ age: 0,
77
+ timeoutAt,
78
+ updatedAt: now,
79
+ };
80
+ continue;
81
+ }
82
+
83
+ if (fiber.status === 'ready') {
84
+ nextFibers[id] = {
85
+ ...fiber,
86
+ age: fiber.age + Math.max(0, state.options.agingStep),
87
+ updatedAt: now,
88
+ };
89
+ }
90
+ }
91
+
92
+ const effects: FiberEffect<TSchema>[] = [];
93
+ const withStep = nextFibers[selectedFiberId];
94
+ if (withStep?.step) {
95
+ effects.push({
96
+ kind: 'send',
97
+ fiberId: selectedFiberId,
98
+ to: withStep.actorId,
99
+ step: withStep.step,
100
+ });
101
+ }
102
+
103
+ return {
104
+ state: {
105
+ ...state,
106
+ fibers: nextFibers,
107
+ },
108
+ effects,
109
+ selectedFiberId,
110
+ };
111
+ }
@@ -0,0 +1,159 @@
1
+ import type { MailboxSchema } from '../core/types';
2
+
3
+ export type FiberId = string;
4
+
5
+ export type FiberStatus =
6
+ | 'ready'
7
+ | 'running'
8
+ | 'suspended'
9
+ | 'completed'
10
+ | 'cancelled'
11
+ | 'failed'
12
+ | 'dead_letter';
13
+
14
+ // Core should not bake in domain-specific waiting reasons.
15
+ export type FiberWaitingReason = string;
16
+
17
+ export type SuspendPolicy = 'continue_others' | 'pause_all';
18
+
19
+ // Core keeps the lane mechanism but treats lane ids as opaque.
20
+ export type FiberLane = string;
21
+
22
+ export type SchedulerFilterCandidate<TSchema extends MailboxSchema> = (args: {
23
+ state: OrchestratorState<TSchema>;
24
+ fiber: FiberRecord<TSchema>;
25
+ now: number;
26
+ }) => boolean;
27
+
28
+ export type SchedulerFilterCandidates<TSchema extends MailboxSchema> = (args: {
29
+ state: OrchestratorState<TSchema>;
30
+ candidates: Array<FiberRecord<TSchema>>;
31
+ now: number;
32
+ }) => Array<FiberRecord<TSchema>>;
33
+
34
+ export interface SchedulerHooks<TSchema extends MailboxSchema> {
35
+ filterCandidate?: SchedulerFilterCandidate<TSchema>;
36
+ filterCandidates?: SchedulerFilterCandidates<TSchema>;
37
+ }
38
+
39
+ export type FiberStep<TSchema extends MailboxSchema> = {
40
+ [K in keyof TSchema & string]: {
41
+ tag: K;
42
+ payload: TSchema[K];
43
+ }
44
+ }[keyof TSchema & string];
45
+
46
+ export interface FiberRecord<TSchema extends MailboxSchema> {
47
+ id: FiberId;
48
+ actorId: string;
49
+ parentId?: FiberId;
50
+ childIds: FiberId[];
51
+ lane: FiberLane;
52
+ status: FiberStatus;
53
+ basePriority: number;
54
+ age: number;
55
+ attempts: number;
56
+ maxAttempts: number;
57
+ step?: FiberStep<TSchema>;
58
+ waitingReason?: FiberWaitingReason;
59
+ suspendPolicy?: SuspendPolicy;
60
+ timeoutMs?: number;
61
+ timeoutAt?: number;
62
+ retryAt?: number;
63
+ lastError?: string;
64
+ order: number;
65
+ createdAt: number;
66
+ updatedAt: number;
67
+ }
68
+
69
+ export interface SpawnFiberInput<TSchema extends MailboxSchema> {
70
+ id: FiberId;
71
+ actorId: string;
72
+ parentId?: FiberId;
73
+ basePriority: number;
74
+ lane?: FiberLane;
75
+ maxAttempts?: number;
76
+ step?: FiberStep<TSchema>;
77
+ timeoutMs?: number;
78
+ }
79
+
80
+ export interface DeadLetterRecord<TSchema extends MailboxSchema> {
81
+ fiberId: FiberId;
82
+ actorId: string;
83
+ reason: string;
84
+ at: number;
85
+ attempts: number;
86
+ step?: FiberStep<TSchema>;
87
+ }
88
+
89
+ export interface OrchestratorOptions<TSchema extends MailboxSchema> {
90
+ senderId: string;
91
+ agingStep: number;
92
+ defaultSuspendPolicy: SuspendPolicy;
93
+ schedulerHooks?: SchedulerHooks<TSchema>;
94
+ timeoutEnabled: boolean;
95
+ defaultTimeoutMs: number;
96
+ retryEnabled: boolean;
97
+ retryDelayMs: number;
98
+ retryBackoffMultiplier: number;
99
+ deadLetterEnabled: boolean;
100
+ deadLetterFactory?: (
101
+ fiber: FiberRecord<TSchema>,
102
+ reason: string,
103
+ ) => { to: string; step: FiberStep<TSchema> } | null;
104
+ }
105
+
106
+ export interface OrchestratorState<TSchema extends MailboxSchema> {
107
+ options: OrchestratorOptions<TSchema>;
108
+ fibers: Record<FiberId, FiberRecord<TSchema>>;
109
+ deadLetters: DeadLetterRecord<TSchema>[];
110
+ sequence: number;
111
+ }
112
+
113
+ export type FiberAction<TSchema extends MailboxSchema> =
114
+ | { type: 'spawn'; fiber: SpawnFiberInput<TSchema>; now: number }
115
+ | { type: 'yield'; fiberId: FiberId; now: number; nextStep?: FiberStep<TSchema> }
116
+ | {
117
+ type: 'suspend';
118
+ fiberId: FiberId;
119
+ now: number;
120
+ reason: FiberWaitingReason;
121
+ suspendPolicy?: SuspendPolicy;
122
+ }
123
+ | { type: 'resume'; fiberId: FiberId; now: number; nextStep?: FiberStep<TSchema> }
124
+ | { type: 'complete'; fiberId: FiberId; now: number }
125
+ | { type: 'fail'; fiberId: FiberId; now: number; error: string }
126
+ | { type: 'cancel'; fiberId: FiberId; now: number; reason: string; propagateToChildren?: boolean }
127
+ | { type: 'tick'; now: number };
128
+
129
+ export type FiberEffect<TSchema extends MailboxSchema> =
130
+ | {
131
+ kind: 'send';
132
+ fiberId: FiberId;
133
+ to: string;
134
+ step: FiberStep<TSchema>;
135
+ }
136
+ | {
137
+ kind: 'dead_letter';
138
+ fiberId: FiberId;
139
+ reason: string;
140
+ to?: string;
141
+ step?: FiberStep<TSchema>;
142
+ };
143
+
144
+ export interface ReduceResult<TSchema extends MailboxSchema> {
145
+ state: OrchestratorState<TSchema>;
146
+ effects: FiberEffect<TSchema>[];
147
+ }
148
+
149
+ export const DEFAULT_ORCHESTRATOR_OPTIONS: Omit<OrchestratorOptions<MailboxSchema>, 'deadLetterFactory'> = {
150
+ senderId: '__fiber_scheduler__',
151
+ agingStep: 1,
152
+ defaultSuspendPolicy: 'continue_others',
153
+ timeoutEnabled: false,
154
+ defaultTimeoutMs: 0,
155
+ retryEnabled: false,
156
+ retryDelayMs: 0,
157
+ retryBackoffMultiplier: 1,
158
+ deadLetterEnabled: false,
159
+ };