curfew 0.0.0-stage → 0.1.1
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 +183 -2
- package/dist/index.cjs +114 -0
- package/dist/index.d.cts +52 -0
- package/dist/index.d.mts +52 -0
- package/dist/index.mjs +108 -0
- package/package.json +62 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,184 @@
|
|
|
1
|
-
#
|
|
1
|
+
# curfew
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run batches from an (async) iterable and stop **before** a time budget runs out.
|
|
4
|
+
|
|
5
|
+
Built for AWS Lambda (15 min hard limit) and similar time-limited runtimes: instead of being killed mid-batch, `curfew` won't start a batch that probably can't finish, and tells you to reschedule the rest.
|
|
6
|
+
|
|
7
|
+
- No runtime dependencies
|
|
8
|
+
- Pluggable time budget and duration estimator
|
|
9
|
+
- In-flight batches are never killed; they get an `AbortSignal` instead
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install curfew
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Node.js 22+. Ships ESM and CommonJS.
|
|
18
|
+
|
|
19
|
+
## Quick start (AWS Lambda)
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { LambdaBudget, runBatches } from 'curfew'
|
|
23
|
+
|
|
24
|
+
export const handler = async (event, context) => {
|
|
25
|
+
let offset = event.offset
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { batchesProcessed, mayHaveMore } = await runBatches(
|
|
29
|
+
fetchPages(offset),
|
|
30
|
+
async (page, { signal }) => {
|
|
31
|
+
await processPage(page, { signal })
|
|
32
|
+
offset++
|
|
33
|
+
},
|
|
34
|
+
{ budget: new LambdaBudget(context, 60_000) },
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if (mayHaveMore) {
|
|
38
|
+
await reenqueue({ offset: event.offset + batchesProcessed })
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// The signal aborted mid-page: an expected timeout, not a failure.
|
|
42
|
+
// `curfew` passes handler errors through, so catch it here and resume from the interrupted page.
|
|
43
|
+
if (!(error instanceof Error && error.name === 'AbortError')) throw error
|
|
44
|
+
await reenqueue({ offset })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Example
|
|
50
|
+
|
|
51
|
+
[`examples/basic.ts`](examples/basic.ts) processes 10 pages with a 1-second budget per run. It shows the three ways a run ends, and how to resume after each:
|
|
52
|
+
|
|
53
|
+
- **Budget runs out mid-page.** `saveItem` passes `ctx.signal` to its async work, so it rejects with an `AbortError`. The example's `runOnce` catches that error (`curfew` passes handler errors through) and resumes from the interrupted page, which is why page processing must be idempotent.
|
|
54
|
+
- **The next page won't fit.** `reason` is `estimate-exceeds-budget`, and the next run starts at the following page.
|
|
55
|
+
- **All pages done.** `reason` is `done`.
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npm run build && node examples/basic.ts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
run 1: starting at page 0
|
|
63
|
+
stopped: budget ran out during page 4
|
|
64
|
+
run 2: starting at page 4
|
|
65
|
+
stopped: estimate-exceeds-budget
|
|
66
|
+
run 3: starting at page 5
|
|
67
|
+
stopped: done
|
|
68
|
+
all pages processed
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Requires Node.js 22.18+ to run `.ts` files directly.
|
|
72
|
+
|
|
73
|
+
## How it works
|
|
74
|
+
|
|
75
|
+
Before pulling each batch, `runBatches` checks the budget. It stops and returns `mayHaveMore: true` with a `reason` if:
|
|
76
|
+
|
|
77
|
+
- the budget is used up (`StopReason.BUDGET_EXHAUSTED`), or
|
|
78
|
+
- the estimated batch duration is longer than the time remaining (`StopReason.ESTIMATE_EXCEEDS_BUDGET`).
|
|
79
|
+
|
|
80
|
+
When the source runs out, it returns `mayHaveMore: false` with `StopReason.DONE`.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { StopReason } from 'curfew'
|
|
84
|
+
|
|
85
|
+
if (result.reason === StopReason.ESTIMATE_EXCEEDS_BUDGET) {
|
|
86
|
+
console.log('stopped with time left; consider a smaller batch size')
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The duration of a batch covers both fetching it from the source (`iterator.next()`) and running the handler, so slow async sources such as DB cursors or paginated APIs are counted. Durations are measured with `performance.now()`, so system clock changes don't affect the estimate.
|
|
91
|
+
|
|
92
|
+
Batches run one at a time. Items from a sync iterable are passed to the handler as is: a sync iterable of promises hands the handler the promises themselves, not their values. A batch that has started is never interrupted: when the budget runs out, `ctx.signal` aborts with a `TimeoutError` reason (the same as `AbortSignal.timeout()`), so cooperative code such as `fetch(url, { signal })` can stop early.
|
|
93
|
+
|
|
94
|
+
- If fetching a batch overruns the budget, the handler still runs, but `ctx.signal` is already aborted. Check `signal.aborted` first if that matters.
|
|
95
|
+
- The abort timer is armed once, from `budget.remainingMs()` at the start of the run. With a custom budget that isn't based on the clock, the signal and the decision to stop can disagree.
|
|
96
|
+
- For budgets longer than about 24.8 days (the `setTimeout` limit), including `Infinity`, the signal never aborts. The budget check before each batch still applies.
|
|
97
|
+
|
|
98
|
+
Handler errors propagate unchanged.
|
|
99
|
+
|
|
100
|
+
The budget is checked before pulling the next item, so no item is ever pulled and then dropped. As a result, `mayHaveMore: true` means "there may be more work", not "there is more work": if the budget runs out just as the source is exhausted, the next run simply processes 0 batches and returns `mayHaveMore: false`.
|
|
101
|
+
|
|
102
|
+
### The source is not closed
|
|
103
|
+
|
|
104
|
+
`runBatches` never calls `iterator.return()`, whether it finishes, stops early or the handler throws. You own the source's cleanup. You can resume either by offset (`batchesProcessed`) in a new invocation, or by passing the same iterator or generator to `runBatches` again. An array (or any iterable that creates a fresh iterator each time) starts again from the beginning, so resume it by offset instead:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
const source = generateWork()
|
|
108
|
+
const first = await runBatches(source, handle, { budget })
|
|
109
|
+
if (first.mayHaveMore) await runBatches(source, handle, { budget: nextBudget })
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Budgets
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { DeadlineBudget, FixedBudget, LambdaBudget } from 'curfew'
|
|
116
|
+
|
|
117
|
+
new FixedBudget(13 * 60_000) // 13 minutes from now
|
|
118
|
+
new DeadlineBudget(new Date('2026-12-01')) // until an absolute time (Date or epoch ms)
|
|
119
|
+
new LambdaBudget(context, 60_000) // Lambda remaining time minus a 60 s margin
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Custom budget
|
|
123
|
+
|
|
124
|
+
Implement `TimeBudget`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import type { TimeBudget } from 'curfew'
|
|
128
|
+
|
|
129
|
+
class JobBudget implements TimeBudget {
|
|
130
|
+
constructor(private job: { expiresAt: number }) {}
|
|
131
|
+
remainingMs() {
|
|
132
|
+
return this.job.expiresAt - Date.now()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Estimators
|
|
138
|
+
|
|
139
|
+
By default, the estimate is the longest batch seen so far (`MaxEstimator`), so `runBatches` errs toward stopping early. Before any batch has run, the estimate is `0`, so the first batch runs as long as any time remains. Pass `new MaxEstimator(initialMs)` to require that much time for the first batch:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { MaxEstimator, runBatches } from 'curfew'
|
|
143
|
+
|
|
144
|
+
await runBatches(source, handle, { budget, estimator: new MaxEstimator(30_000) })
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Custom estimator
|
|
148
|
+
|
|
149
|
+
Implement `Estimator`. `record` is called with each batch's duration, and `estimateMs` is consulted before each batch:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import type { Estimator } from 'curfew'
|
|
153
|
+
|
|
154
|
+
class EwmaEstimator implements Estimator {
|
|
155
|
+
private value = 0
|
|
156
|
+
constructor(private alpha = 0.3) {}
|
|
157
|
+
record(ms: number) {
|
|
158
|
+
this.value = this.value === 0 ? ms : this.alpha * ms + (1 - this.alpha) * this.value
|
|
159
|
+
}
|
|
160
|
+
estimateMs() {
|
|
161
|
+
return this.value
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## API
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
runBatches<T>(
|
|
170
|
+
source: Iterable<T> | AsyncIterable<T>,
|
|
171
|
+
handler: (batch: T, ctx: { signal: AbortSignal; index: number }) => Promise<void> | void,
|
|
172
|
+
options: { budget: TimeBudget; estimator?: Estimator },
|
|
173
|
+
): Promise<{ batchesProcessed: number; mayHaveMore: boolean; reason: StopReason }>
|
|
174
|
+
|
|
175
|
+
enum StopReason {
|
|
176
|
+
DONE = 'done',
|
|
177
|
+
BUDGET_EXHAUSTED = 'budget-exhausted',
|
|
178
|
+
ESTIMATE_EXCEEDS_BUDGET = 'estimate-exceeds-budget',
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
// src/budgets/DeadlineBudget.ts
|
|
3
|
+
var DeadlineBudget = class {
|
|
4
|
+
deadline;
|
|
5
|
+
constructor(deadline) {
|
|
6
|
+
this.deadline = deadline instanceof Date ? deadline.getTime() : deadline;
|
|
7
|
+
}
|
|
8
|
+
remainingMs() {
|
|
9
|
+
return this.deadline - Date.now();
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
// src/budgets/FixedBudget.ts
|
|
13
|
+
var FixedBudget = class extends DeadlineBudget {
|
|
14
|
+
constructor(durationMs) {
|
|
15
|
+
super(Date.now() + durationMs);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
// src/budgets/LambdaBudget.ts
|
|
19
|
+
var LambdaBudget = class {
|
|
20
|
+
context;
|
|
21
|
+
safetyMarginMs;
|
|
22
|
+
constructor(context, safetyMarginMs) {
|
|
23
|
+
this.context = context;
|
|
24
|
+
this.safetyMarginMs = safetyMarginMs;
|
|
25
|
+
}
|
|
26
|
+
remainingMs() {
|
|
27
|
+
return this.context.getRemainingTimeInMillis() - this.safetyMarginMs;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
// src/estimators/MaxEstimator.ts
|
|
31
|
+
var MaxEstimator = class {
|
|
32
|
+
max;
|
|
33
|
+
constructor(initialMs = 0) {
|
|
34
|
+
this.max = initialMs;
|
|
35
|
+
}
|
|
36
|
+
record(ms) {
|
|
37
|
+
this.max = Math.max(this.max, ms);
|
|
38
|
+
}
|
|
39
|
+
estimateMs() {
|
|
40
|
+
return this.max;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
// src/runner/StopReason.ts
|
|
44
|
+
let StopReason = /* @__PURE__ */ function(StopReason) {
|
|
45
|
+
StopReason["DONE"] = "done";
|
|
46
|
+
StopReason["BUDGET_EXHAUSTED"] = "budget-exhausted";
|
|
47
|
+
StopReason["ESTIMATE_EXCEEDS_BUDGET"] = "estimate-exceeds-budget";
|
|
48
|
+
return StopReason;
|
|
49
|
+
}({});
|
|
50
|
+
// src/runner/RunBatches.ts
|
|
51
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
52
|
+
async function runBatches(source, handler, { budget, estimator = new MaxEstimator() }) {
|
|
53
|
+
const batches = getIterator(source);
|
|
54
|
+
const deadline = abortWhenBudgetRunsOut(budget);
|
|
55
|
+
let batchesProcessed = 0;
|
|
56
|
+
try {
|
|
57
|
+
while (true) {
|
|
58
|
+
const reason = reasonToStopBeforeNextBatch(budget, estimator);
|
|
59
|
+
if (reason) return {
|
|
60
|
+
batchesProcessed,
|
|
61
|
+
mayHaveMore: true,
|
|
62
|
+
reason
|
|
63
|
+
};
|
|
64
|
+
const startedAt = performance.now();
|
|
65
|
+
const next = await batches.next();
|
|
66
|
+
if (next.done) return {
|
|
67
|
+
batchesProcessed,
|
|
68
|
+
mayHaveMore: false,
|
|
69
|
+
reason: "done"
|
|
70
|
+
};
|
|
71
|
+
await handler(next.value, {
|
|
72
|
+
signal: deadline.signal,
|
|
73
|
+
index: batchesProcessed
|
|
74
|
+
});
|
|
75
|
+
estimator.record(performance.now() - startedAt);
|
|
76
|
+
batchesProcessed++;
|
|
77
|
+
}
|
|
78
|
+
} finally {
|
|
79
|
+
deadline.cancel();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function reasonToStopBeforeNextBatch(budget, estimator) {
|
|
83
|
+
const remainingMs = budget.remainingMs();
|
|
84
|
+
if (!(remainingMs > 0)) return "budget-exhausted";
|
|
85
|
+
if (!(estimator.estimateMs() <= remainingMs)) return "estimate-exceeds-budget";
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
function abortWhenBudgetRunsOut(budget) {
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
const remainingMs = budget.remainingMs();
|
|
91
|
+
const delayMs = remainingMs > 0 ? remainingMs : 0;
|
|
92
|
+
if (delayMs > MAX_TIMER_DELAY_MS) return {
|
|
93
|
+
signal: controller.signal,
|
|
94
|
+
cancel: () => {}
|
|
95
|
+
};
|
|
96
|
+
const timer = setTimeout(() => controller.abort(new DOMException("Budget exhausted", "TimeoutError")), delayMs);
|
|
97
|
+
timer.unref();
|
|
98
|
+
return {
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
cancel: () => clearTimeout(timer)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function getIterator(source) {
|
|
104
|
+
return isAsyncIterable(source) ? source[Symbol.asyncIterator]() : source[Symbol.iterator]();
|
|
105
|
+
}
|
|
106
|
+
function isAsyncIterable(source) {
|
|
107
|
+
return typeof source[Symbol.asyncIterator] === "function";
|
|
108
|
+
}
|
|
109
|
+
exports.DeadlineBudget = DeadlineBudget;
|
|
110
|
+
exports.FixedBudget = FixedBudget;
|
|
111
|
+
exports.LambdaBudget = LambdaBudget;
|
|
112
|
+
exports.MaxEstimator = MaxEstimator;
|
|
113
|
+
exports.StopReason = StopReason;
|
|
114
|
+
exports.runBatches = runBatches;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
interface TimeBudget {
|
|
2
|
+
remainingMs(): number;
|
|
3
|
+
}
|
|
4
|
+
export declare class DeadlineBudget implements TimeBudget {
|
|
5
|
+
private readonly deadline;
|
|
6
|
+
constructor(deadline: Date | number);
|
|
7
|
+
remainingMs(): number;
|
|
8
|
+
}
|
|
9
|
+
export declare class FixedBudget extends DeadlineBudget {
|
|
10
|
+
constructor(durationMs: number);
|
|
11
|
+
}
|
|
12
|
+
interface LambdaContextLike {
|
|
13
|
+
getRemainingTimeInMillis(): number;
|
|
14
|
+
}
|
|
15
|
+
export declare class LambdaBudget implements TimeBudget {
|
|
16
|
+
private readonly context;
|
|
17
|
+
private readonly safetyMarginMs;
|
|
18
|
+
constructor(context: LambdaContextLike, safetyMarginMs: number);
|
|
19
|
+
remainingMs(): number;
|
|
20
|
+
}
|
|
21
|
+
interface Estimator {
|
|
22
|
+
record(ms: number): void;
|
|
23
|
+
estimateMs(): number;
|
|
24
|
+
}
|
|
25
|
+
export declare class MaxEstimator implements Estimator {
|
|
26
|
+
private max;
|
|
27
|
+
constructor(initialMs?: number);
|
|
28
|
+
record(ms: number): void;
|
|
29
|
+
estimateMs(): number;
|
|
30
|
+
}
|
|
31
|
+
interface BatchContext {
|
|
32
|
+
signal: AbortSignal;
|
|
33
|
+
index: number;
|
|
34
|
+
}
|
|
35
|
+
type BatchHandler<T> = (batch: T, ctx: BatchContext) => Promise<void> | void;
|
|
36
|
+
type BatchSource<T> = Iterable<T> | AsyncIterable<T>;
|
|
37
|
+
interface RunOptions {
|
|
38
|
+
budget: TimeBudget;
|
|
39
|
+
estimator?: Estimator;
|
|
40
|
+
}
|
|
41
|
+
export declare enum StopReason {
|
|
42
|
+
DONE = "done",
|
|
43
|
+
BUDGET_EXHAUSTED = "budget-exhausted",
|
|
44
|
+
ESTIMATE_EXCEEDS_BUDGET = "estimate-exceeds-budget"
|
|
45
|
+
}
|
|
46
|
+
interface RunResult {
|
|
47
|
+
batchesProcessed: number;
|
|
48
|
+
mayHaveMore: boolean;
|
|
49
|
+
reason: StopReason;
|
|
50
|
+
}
|
|
51
|
+
export declare function runBatches<T>(source: BatchSource<T>, handler: BatchHandler<T>, { budget, estimator }: RunOptions): Promise<RunResult>;
|
|
52
|
+
export type { BatchContext, BatchHandler, BatchSource, Estimator, LambdaContextLike, RunOptions, RunResult, TimeBudget };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
interface TimeBudget {
|
|
2
|
+
remainingMs(): number;
|
|
3
|
+
}
|
|
4
|
+
export declare class DeadlineBudget implements TimeBudget {
|
|
5
|
+
private readonly deadline;
|
|
6
|
+
constructor(deadline: Date | number);
|
|
7
|
+
remainingMs(): number;
|
|
8
|
+
}
|
|
9
|
+
export declare class FixedBudget extends DeadlineBudget {
|
|
10
|
+
constructor(durationMs: number);
|
|
11
|
+
}
|
|
12
|
+
interface LambdaContextLike {
|
|
13
|
+
getRemainingTimeInMillis(): number;
|
|
14
|
+
}
|
|
15
|
+
export declare class LambdaBudget implements TimeBudget {
|
|
16
|
+
private readonly context;
|
|
17
|
+
private readonly safetyMarginMs;
|
|
18
|
+
constructor(context: LambdaContextLike, safetyMarginMs: number);
|
|
19
|
+
remainingMs(): number;
|
|
20
|
+
}
|
|
21
|
+
interface Estimator {
|
|
22
|
+
record(ms: number): void;
|
|
23
|
+
estimateMs(): number;
|
|
24
|
+
}
|
|
25
|
+
export declare class MaxEstimator implements Estimator {
|
|
26
|
+
private max;
|
|
27
|
+
constructor(initialMs?: number);
|
|
28
|
+
record(ms: number): void;
|
|
29
|
+
estimateMs(): number;
|
|
30
|
+
}
|
|
31
|
+
interface BatchContext {
|
|
32
|
+
signal: AbortSignal;
|
|
33
|
+
index: number;
|
|
34
|
+
}
|
|
35
|
+
type BatchHandler<T> = (batch: T, ctx: BatchContext) => Promise<void> | void;
|
|
36
|
+
type BatchSource<T> = Iterable<T> | AsyncIterable<T>;
|
|
37
|
+
interface RunOptions {
|
|
38
|
+
budget: TimeBudget;
|
|
39
|
+
estimator?: Estimator;
|
|
40
|
+
}
|
|
41
|
+
export declare enum StopReason {
|
|
42
|
+
DONE = "done",
|
|
43
|
+
BUDGET_EXHAUSTED = "budget-exhausted",
|
|
44
|
+
ESTIMATE_EXCEEDS_BUDGET = "estimate-exceeds-budget"
|
|
45
|
+
}
|
|
46
|
+
interface RunResult {
|
|
47
|
+
batchesProcessed: number;
|
|
48
|
+
mayHaveMore: boolean;
|
|
49
|
+
reason: StopReason;
|
|
50
|
+
}
|
|
51
|
+
export declare function runBatches<T>(source: BatchSource<T>, handler: BatchHandler<T>, { budget, estimator }: RunOptions): Promise<RunResult>;
|
|
52
|
+
export type { BatchContext, BatchHandler, BatchSource, Estimator, LambdaContextLike, RunOptions, RunResult, TimeBudget };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/budgets/DeadlineBudget.ts
|
|
2
|
+
var DeadlineBudget = class {
|
|
3
|
+
deadline;
|
|
4
|
+
constructor(deadline) {
|
|
5
|
+
this.deadline = deadline instanceof Date ? deadline.getTime() : deadline;
|
|
6
|
+
}
|
|
7
|
+
remainingMs() {
|
|
8
|
+
return this.deadline - Date.now();
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
// src/budgets/FixedBudget.ts
|
|
12
|
+
var FixedBudget = class extends DeadlineBudget {
|
|
13
|
+
constructor(durationMs) {
|
|
14
|
+
super(Date.now() + durationMs);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
// src/budgets/LambdaBudget.ts
|
|
18
|
+
var LambdaBudget = class {
|
|
19
|
+
context;
|
|
20
|
+
safetyMarginMs;
|
|
21
|
+
constructor(context, safetyMarginMs) {
|
|
22
|
+
this.context = context;
|
|
23
|
+
this.safetyMarginMs = safetyMarginMs;
|
|
24
|
+
}
|
|
25
|
+
remainingMs() {
|
|
26
|
+
return this.context.getRemainingTimeInMillis() - this.safetyMarginMs;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
// src/estimators/MaxEstimator.ts
|
|
30
|
+
var MaxEstimator = class {
|
|
31
|
+
max;
|
|
32
|
+
constructor(initialMs = 0) {
|
|
33
|
+
this.max = initialMs;
|
|
34
|
+
}
|
|
35
|
+
record(ms) {
|
|
36
|
+
this.max = Math.max(this.max, ms);
|
|
37
|
+
}
|
|
38
|
+
estimateMs() {
|
|
39
|
+
return this.max;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
// src/runner/StopReason.ts
|
|
43
|
+
let StopReason = /* @__PURE__ */ function(StopReason) {
|
|
44
|
+
StopReason["DONE"] = "done";
|
|
45
|
+
StopReason["BUDGET_EXHAUSTED"] = "budget-exhausted";
|
|
46
|
+
StopReason["ESTIMATE_EXCEEDS_BUDGET"] = "estimate-exceeds-budget";
|
|
47
|
+
return StopReason;
|
|
48
|
+
}({});
|
|
49
|
+
// src/runner/RunBatches.ts
|
|
50
|
+
const MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
|
|
51
|
+
async function runBatches(source, handler, { budget, estimator = new MaxEstimator() }) {
|
|
52
|
+
const batches = getIterator(source);
|
|
53
|
+
const deadline = abortWhenBudgetRunsOut(budget);
|
|
54
|
+
let batchesProcessed = 0;
|
|
55
|
+
try {
|
|
56
|
+
while (true) {
|
|
57
|
+
const reason = reasonToStopBeforeNextBatch(budget, estimator);
|
|
58
|
+
if (reason) return {
|
|
59
|
+
batchesProcessed,
|
|
60
|
+
mayHaveMore: true,
|
|
61
|
+
reason
|
|
62
|
+
};
|
|
63
|
+
const startedAt = performance.now();
|
|
64
|
+
const next = await batches.next();
|
|
65
|
+
if (next.done) return {
|
|
66
|
+
batchesProcessed,
|
|
67
|
+
mayHaveMore: false,
|
|
68
|
+
reason: "done"
|
|
69
|
+
};
|
|
70
|
+
await handler(next.value, {
|
|
71
|
+
signal: deadline.signal,
|
|
72
|
+
index: batchesProcessed
|
|
73
|
+
});
|
|
74
|
+
estimator.record(performance.now() - startedAt);
|
|
75
|
+
batchesProcessed++;
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
deadline.cancel();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function reasonToStopBeforeNextBatch(budget, estimator) {
|
|
82
|
+
const remainingMs = budget.remainingMs();
|
|
83
|
+
if (!(remainingMs > 0)) return "budget-exhausted";
|
|
84
|
+
if (!(estimator.estimateMs() <= remainingMs)) return "estimate-exceeds-budget";
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
function abortWhenBudgetRunsOut(budget) {
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const remainingMs = budget.remainingMs();
|
|
90
|
+
const delayMs = remainingMs > 0 ? remainingMs : 0;
|
|
91
|
+
if (delayMs > MAX_TIMER_DELAY_MS) return {
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
cancel: () => {}
|
|
94
|
+
};
|
|
95
|
+
const timer = setTimeout(() => controller.abort(new DOMException("Budget exhausted", "TimeoutError")), delayMs);
|
|
96
|
+
timer.unref();
|
|
97
|
+
return {
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
cancel: () => clearTimeout(timer)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function getIterator(source) {
|
|
103
|
+
return isAsyncIterable(source) ? source[Symbol.asyncIterator]() : source[Symbol.iterator]();
|
|
104
|
+
}
|
|
105
|
+
function isAsyncIterable(source) {
|
|
106
|
+
return typeof source[Symbol.asyncIterator] === "function";
|
|
107
|
+
}
|
|
108
|
+
export { DeadlineBudget, FixedBudget, LambdaBudget, MaxEstimator, StopReason, runBatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "curfew",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Run batches from an (async) iterable and stop before a time budget runs out.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"batch",
|
|
7
|
+
"deadline",
|
|
8
|
+
"timeout",
|
|
9
|
+
"time-budget",
|
|
10
|
+
"lambda",
|
|
11
|
+
"aws-lambda",
|
|
12
|
+
"abortsignal",
|
|
13
|
+
"graceful-shutdown"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Pushkal Guha Choudhury",
|
|
17
|
+
"homepage": "https://github.com/Maganlal-Meghraj/curfew#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/Maganlal-Meghraj/curfew.git"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22"
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"types": "./dist/index.d.cts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"import": {
|
|
35
|
+
"types": "./dist/index.d.mts",
|
|
36
|
+
"default": "./dist/index.mjs"
|
|
37
|
+
},
|
|
38
|
+
"require": {
|
|
39
|
+
"types": "./dist/index.d.cts",
|
|
40
|
+
"default": "./dist/index.cjs"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsdown",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"typecheck": "tsc",
|
|
49
|
+
"lint": "eslint .",
|
|
50
|
+
"lint:pkg": "publint && attw --pack .",
|
|
51
|
+
"prepublishOnly": "npm run typecheck && npm run lint && npm test && npm run build && npm run lint:pkg"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
55
|
+
"@eslint/js": "^10.0.1",
|
|
56
|
+
"@types/node": "^22.20.5",
|
|
57
|
+
"eslint": "^10.14.0",
|
|
58
|
+
"publint": "^0.3.25",
|
|
59
|
+
"tsdown": "0.23.7",
|
|
60
|
+
"typescript": "^6.0.3",
|
|
61
|
+
"typescript-eslint": "^8.72.1",
|
|
62
|
+
"vitest": "^5.1.0"
|
|
63
|
+
}
|
|
64
|
+
}
|