@empyria/restate 0.4.12 → 0.4.14
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/AGENTS.md +5 -0
- package/README.md +13 -1
- package/lib/Admin.js +42 -8
- package/lib/Cron.js +27 -2
- package/package.json +1 -1
- package/test/Admin.test.js +37 -0
- package/test/Cron.test.js +71 -2
package/AGENTS.md
CHANGED
|
@@ -74,6 +74,11 @@ Attempted to assign to readonly property`). Use `mock.module('@restatedev/restat
|
|
|
74
74
|
any mocking) if you need to restore it — reading it back off the live `clients` binding later
|
|
75
75
|
may observe an already-mocked value. See `test/Admin.test.js`.
|
|
76
76
|
|
|
77
|
+
`setupRestate`'s shutdown decision is `shouldDeleteOnShutdown` (unit tested); `keepDeploymentOnShutdown` and
|
|
78
|
+
`forceRegistration` exist because invocations are pinned to their deployment: Restate 1.7 keeps the deployment ID when the
|
|
79
|
+
same address registers again, but only `force: true` updates its handler list. Check both against a live server when
|
|
80
|
+
touching them.
|
|
81
|
+
|
|
77
82
|
`setupRestate` (in `lib/Admin.js`) is not unit tested: it binds real HTTP/2 + health-check
|
|
78
83
|
ports, installs process-wide `SIGTERM`/`SIGINT` handlers, and its returned `forceClose` calls
|
|
79
84
|
`process.exit()` — none of which are safe to exercise in-process in a test run.
|
package/README.md
CHANGED
|
@@ -118,13 +118,25 @@ endpoint, then create jobs with either:
|
|
|
118
118
|
starts with the application.** The job has the stable `id` you give it, so calling it on every start never adds a second
|
|
119
119
|
job: the same request changes nothing, and a changed request (another schedule, target or payload) replaces the pending
|
|
120
120
|
run under the same ID. Call it once the endpoint has registered, e.g. from `setupRestate`'s `registrationCallbackFn`.
|
|
121
|
+
It also repairs a job whose chain broke: each run schedules the next, so a pending run that was killed, cancelled or
|
|
122
|
+
lost with a deleted deployment would stop the job for good. When the same request finds its pending run overdue by
|
|
123
|
+
more than `OVERDUE_GRACE_MS` (60 s), `ensure` schedules a fresh one; a replaced run that still arrives does nothing,
|
|
124
|
+
because a run only works when it is the job's current one.
|
|
121
125
|
|
|
122
126
|
Cron expressions are `croner` patterns (five fields, or six with seconds for tests). The target handler is sent the
|
|
123
127
|
`payload` as its input, or no input when there is none.
|
|
124
128
|
|
|
125
129
|
### An endpoint that does not register itself
|
|
126
130
|
|
|
127
|
-
`setupRestate` serves, registers the deployment, and deletes it again on SIGINT/SIGTERM.
|
|
131
|
+
`setupRestate` serves, registers the deployment, and deletes it again on SIGINT/SIGTERM. Invocations in flight are
|
|
132
|
+
pinned to their deployment, so deleting it strands them: a restarted process cannot finish them and they can only be
|
|
133
|
+
killed. For a process restarted at the same address, pass `keepDeploymentOnShutdown: true` and `forceRegistration: true`:
|
|
134
|
+
Restate keeps the deployment ID when the same address registers again (checked against Restate 1.7), running
|
|
135
|
+
invocations resume on the new process, and `force` updates the services and handlers in place (a plain
|
|
136
|
+
re-registration keeps the old handler list). Only compatible changes are safe this way: an invocation of a removed or
|
|
137
|
+
renamed handler cannot resume.
|
|
138
|
+
|
|
139
|
+
When something else owns
|
|
128
140
|
registration (a supervisor that registers a process's address once and restarts the process), use
|
|
129
141
|
`serveEndpoint({port, services})` instead: it only serves. Invocations already running on a deployment are pinned to its
|
|
130
142
|
address, so the deployment must survive the process stopping and come back at the same address.
|
package/lib/Admin.js
CHANGED
|
@@ -581,7 +581,15 @@ export function defineObject(def) {
|
|
|
581
581
|
/**
|
|
582
582
|
* Starts the HTTP/2 Restate endpoint, a health-check HTTP server, registers
|
|
583
583
|
* the deployment with the admin API, and wires SIGTERM/SIGINT for graceful
|
|
584
|
-
* shutdown (deregistering the deployment before exiting).
|
|
584
|
+
* shutdown (deregistering the deployment before exiting, unless `keepDeploymentOnShutdown`).
|
|
585
|
+
*
|
|
586
|
+
* Invocations in flight are pinned to their deployment. Deleting it on shutdown means a restarted process cannot
|
|
587
|
+
* finish them, and they cannot even be cancelled (only killed). For a process that is restarted at the same address
|
|
588
|
+
* (a dev loop, a service that is redeployed in place), pass `keepDeploymentOnShutdown: true` and
|
|
589
|
+
* `forceRegistration: true`: Restate keeps the deployment ID when the same address registers again, so running
|
|
590
|
+
* invocations resume on the new process, and `force` updates the deployment's services and handlers in place (a plain
|
|
591
|
+
* re-registration keeps the old handler list). Only compatible changes are safe that way: an invocation of a handler
|
|
592
|
+
* that was removed or renamed cannot resume.
|
|
585
593
|
* @param {Object} params
|
|
586
594
|
* @param {string} params.restateAdminURL
|
|
587
595
|
* @param {string} params.host
|
|
@@ -590,6 +598,9 @@ export function defineObject(def) {
|
|
|
590
598
|
* @param {Parameters<typeof restate.createEndpointHandler>[0]['services']} [params.services]
|
|
591
599
|
* @param {() => void} [params.shutdownCallbackFn]
|
|
592
600
|
* @param {(deploymentId: string) => void} [params.registrationCallbackFn]
|
|
601
|
+
* @param {boolean} [params.keepDeploymentOnShutdown=false] Leave the deployment registered on SIGINT/SIGTERM.
|
|
602
|
+
* @param {boolean} [params.forceRegistration=false] Register with `force: true`, updating an existing deployment at
|
|
603
|
+
* the same address in place.
|
|
593
604
|
* @returns {Promise<RestateServer>}
|
|
594
605
|
*/
|
|
595
606
|
export async function setupRestate({
|
|
@@ -600,6 +611,8 @@ export async function setupRestate({
|
|
|
600
611
|
services = [],
|
|
601
612
|
shutdownCallbackFn,
|
|
602
613
|
registrationCallbackFn,
|
|
614
|
+
keepDeploymentOnShutdown = false,
|
|
615
|
+
forceRegistration = false,
|
|
603
616
|
}) {
|
|
604
617
|
const handler = restate.createEndpointHandler({ services })
|
|
605
618
|
|
|
@@ -647,8 +660,12 @@ export async function setupRestate({
|
|
|
647
660
|
isHealthy = false
|
|
648
661
|
|
|
649
662
|
try {
|
|
650
|
-
if (
|
|
663
|
+
if (shouldDeleteOnShutdown({ deploymentId, keepDeploymentOnShutdown })) {
|
|
651
664
|
await deleteDeployment(restateAdminURL, deploymentId)
|
|
665
|
+
} else if (keepDeploymentOnShutdown && deploymentId) {
|
|
666
|
+
console.log(
|
|
667
|
+
`Keeping deployment ${deploymentId}: invocations in flight resume when it comes back`,
|
|
668
|
+
)
|
|
652
669
|
}
|
|
653
670
|
} catch (err) {
|
|
654
671
|
console.error(err)
|
|
@@ -688,7 +705,11 @@ export async function setupRestate({
|
|
|
688
705
|
|
|
689
706
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
690
707
|
try {
|
|
691
|
-
const result = await registerDeployment({
|
|
708
|
+
const result = await registerDeployment({
|
|
709
|
+
restateAdminURL,
|
|
710
|
+
serviceURL,
|
|
711
|
+
force: forceRegistration,
|
|
712
|
+
})
|
|
692
713
|
|
|
693
714
|
try {
|
|
694
715
|
registrationCallbackFn?.(result.id)
|
|
@@ -751,15 +772,27 @@ export async function setupRestate({
|
|
|
751
772
|
}
|
|
752
773
|
|
|
753
774
|
/**
|
|
754
|
-
*
|
|
755
|
-
*
|
|
775
|
+
* Whether `setupRestate`'s shutdown removes the deployment: only a real one (registered, not `'NA'`), and not when the
|
|
776
|
+
* caller keeps it for the next start.
|
|
777
|
+
* @param {{deploymentId: string|undefined, keepDeploymentOnShutdown?: boolean}} params
|
|
778
|
+
* @returns {boolean}
|
|
779
|
+
*/
|
|
780
|
+
export function shouldDeleteOnShutdown({ deploymentId, keepDeploymentOnShutdown = false }) {
|
|
781
|
+
return Boolean(deploymentId) && deploymentId !== 'NA' && !keepDeploymentOnShutdown
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* POSTs this process's HTTP endpoint to the Restate admin API so its services are discoverable. Registering an
|
|
786
|
+
* address that is already registered returns the same deployment; with `force` its services and handlers are
|
|
787
|
+
* updated in place (without it, Restate keeps the ones it knew).
|
|
788
|
+
* @param {{restateAdminURL: string, serviceURL: string, force?: boolean}} params
|
|
756
789
|
* @returns {Promise<RegisterDeploymentResponse>}
|
|
757
790
|
*/
|
|
758
|
-
export async function registerDeployment({ restateAdminURL, serviceURL }) {
|
|
791
|
+
export async function registerDeployment({ restateAdminURL, serviceURL, force = false }) {
|
|
759
792
|
const uri = serviceURL
|
|
760
793
|
|
|
761
794
|
/** @type {RegisterDeploymentRequest} */
|
|
762
|
-
const body = { uri, force: null }
|
|
795
|
+
const body = { uri, force: force ? true : null }
|
|
763
796
|
|
|
764
797
|
console.log(`Registering: ${uri} at: ${restateAdminURL}/deployments`)
|
|
765
798
|
|
|
@@ -872,7 +905,8 @@ export function createRestateAdmin({ restateAdminURL, restateURL }) {
|
|
|
872
905
|
pollInvocation: ({ invocationId, intervalMs }) =>
|
|
873
906
|
pollInvocation({ restateURL, invocationId, intervalMs }),
|
|
874
907
|
|
|
875
|
-
getInvocationOutput: ({ invocationId }) =>
|
|
908
|
+
getInvocationOutput: ({ invocationId }) =>
|
|
909
|
+
getInvocationOutput({ restateURL, invocationId }),
|
|
876
910
|
|
|
877
911
|
sendMessage: ({ name, message, key, payload, idempotencyKey }) =>
|
|
878
912
|
sendMessage({ restateURL, name, message, key, payload, idempotencyKey }),
|
package/lib/Cron.js
CHANGED
|
@@ -27,6 +27,15 @@ export const RETRY_FOREVER = {
|
|
|
27
27
|
exponentiationFactor: 2,
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How late a job's pending run may be before `ensure` treats it as lost and schedules a fresh one. A run is normally
|
|
32
|
+
* late only by the time the services were down; a run that is late by more than this, when the application starts and
|
|
33
|
+
* calls `ensure`, has most likely been killed, cancelled or lost with a deleted deployment, and nothing else would ever
|
|
34
|
+
* schedule the next one. Replacing a run that is merely slow is harmless: `execute` ignores a run that is no longer
|
|
35
|
+
* the job's current one.
|
|
36
|
+
*/
|
|
37
|
+
export const OVERDUE_GRACE_MS = 60_000
|
|
38
|
+
|
|
30
39
|
export const cronJobInitiator = restate.service({
|
|
31
40
|
name: 'CronJobInitiator',
|
|
32
41
|
handlers: {
|
|
@@ -56,7 +65,8 @@ export const cronJob = restate.object({
|
|
|
56
65
|
* code that starts with the application and cannot know whether the job was created before.
|
|
57
66
|
*
|
|
58
67
|
* - no job yet: schedules it, as `initiate` does;
|
|
59
|
-
* - the same request already scheduled: changes nothing and returns the current state
|
|
68
|
+
* - the same request already scheduled: changes nothing and returns the current state, unless its pending run
|
|
69
|
+
* is overdue by more than {@link OVERDUE_GRACE_MS} (killed, cancelled or lost): then it schedules a fresh one;
|
|
60
70
|
* - a different request (another schedule, target or payload): cancels the pending run and
|
|
61
71
|
* schedules the new request under the same job ID.
|
|
62
72
|
*/
|
|
@@ -64,7 +74,11 @@ export const cronJob = restate.object({
|
|
|
64
74
|
const wanted = normalizeRequest(request)
|
|
65
75
|
const jobState = await ctx.get(JOB_STATE_NAME)
|
|
66
76
|
|
|
67
|
-
if (jobState && sameRequest(jobState.request, wanted))
|
|
77
|
+
if (jobState && sameRequest(jobState.request, wanted)) {
|
|
78
|
+
const dueAt = jobState.next_execution_at ?? Date.parse(jobState.next_execution_time)
|
|
79
|
+
const now = await ctx.date.now()
|
|
80
|
+
if (!(Number.isFinite(dueAt) && now > dueAt + OVERDUE_GRACE_MS)) return jobState
|
|
81
|
+
}
|
|
68
82
|
if (jobState) ctx.cancel(jobState.next_execution_id)
|
|
69
83
|
|
|
70
84
|
return await scheduleNextExecution(ctx, wanted)
|
|
@@ -74,6 +88,15 @@ export const cronJob = restate.object({
|
|
|
74
88
|
if (!jobState) {
|
|
75
89
|
throw new TerminalError('Job not found.')
|
|
76
90
|
}
|
|
91
|
+
// Only the job's current run works and schedules the next one. A run that `ensure` replaced (or that a
|
|
92
|
+
// changed schedule superseded) and that still arrives does nothing, so a job never runs as two chains.
|
|
93
|
+
const self = ctx.request?.().id
|
|
94
|
+
if (self && jobState.next_execution_id && self !== jobState.next_execution_id) {
|
|
95
|
+
ctx.console?.log?.(
|
|
96
|
+
`CronJob ${ctx.key}: run ${self} is not the current one (${jobState.next_execution_id}), skipped`,
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
77
100
|
|
|
78
101
|
// execute the task
|
|
79
102
|
const { service, method, key, payload } = jobState.request
|
|
@@ -156,6 +179,8 @@ const scheduleNextExecution = async (ctx, request) => {
|
|
|
156
179
|
const jobState = {
|
|
157
180
|
request,
|
|
158
181
|
next_execution_time: next.toString(),
|
|
182
|
+
// The same instant as a number, so `ensure` can tell an overdue run without parsing a date string.
|
|
183
|
+
next_execution_at: next.getTime(),
|
|
159
184
|
next_execution_id: await handle.invocationId,
|
|
160
185
|
}
|
|
161
186
|
ctx.set(JOB_STATE_NAME, jobState)
|
package/package.json
CHANGED
package/test/Admin.test.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
sendMessageWithDiscovery,
|
|
21
21
|
sendMessageAsyncWithDiscovery,
|
|
22
22
|
registerDeployment,
|
|
23
|
+
shouldDeleteOnShutdown,
|
|
23
24
|
sendMessageAsynSafe,
|
|
24
25
|
defineWorkflow,
|
|
25
26
|
defineService,
|
|
@@ -521,6 +522,42 @@ describe('registerDeployment', () => {
|
|
|
521
522
|
registerDeployment({ restateAdminURL: 'http://admin', serviceURL: 'http://svc' }),
|
|
522
523
|
).rejects.toThrow(/bad uri/)
|
|
523
524
|
})
|
|
525
|
+
|
|
526
|
+
test('sends force: null by default and force: true when asked, to the deployments endpoint', async () => {
|
|
527
|
+
const sent = []
|
|
528
|
+
globalThis.fetch = mock(async (url, init) => {
|
|
529
|
+
sent.push({ url, body: JSON.parse(init.body) })
|
|
530
|
+
return jsonResponse({ id: 'dep-1', services: [] })
|
|
531
|
+
})
|
|
532
|
+
await registerDeployment({ restateAdminURL: 'http://admin', serviceURL: 'http://svc:9080' })
|
|
533
|
+
await registerDeployment({
|
|
534
|
+
restateAdminURL: 'http://admin',
|
|
535
|
+
serviceURL: 'http://svc:9080',
|
|
536
|
+
force: true,
|
|
537
|
+
})
|
|
538
|
+
expect(sent).toEqual([
|
|
539
|
+
{ url: 'http://admin/deployments', body: { uri: 'http://svc:9080', force: null } },
|
|
540
|
+
{ url: 'http://admin/deployments', body: { uri: 'http://svc:9080', force: true } },
|
|
541
|
+
])
|
|
542
|
+
})
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
describe('shouldDeleteOnShutdown', () => {
|
|
546
|
+
test('deletes a registered deployment by default', () => {
|
|
547
|
+
expect(shouldDeleteOnShutdown({ deploymentId: 'dep-1' })).toBe(true)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
test('keeps it when asked, so invocations in flight can resume after a restart', () => {
|
|
551
|
+
expect(
|
|
552
|
+
shouldDeleteOnShutdown({ deploymentId: 'dep-1', keepDeploymentOnShutdown: true }),
|
|
553
|
+
).toBe(false)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
test('never deletes without a real deployment', () => {
|
|
557
|
+
for (const deploymentId of [undefined, '', 'NA']) {
|
|
558
|
+
expect(shouldDeleteOnShutdown({ deploymentId })).toBe(false)
|
|
559
|
+
}
|
|
560
|
+
})
|
|
524
561
|
})
|
|
525
562
|
|
|
526
563
|
// `clients` is a live ESM namespace binding (read-only — Object.assign/property
|
package/test/Cron.test.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ensureCronJob,
|
|
6
6
|
setJobStateName,
|
|
7
7
|
JOB_STATE_NAME,
|
|
8
|
+
OVERDUE_GRACE_MS,
|
|
8
9
|
} from '../lib/Cron.js'
|
|
9
10
|
import { TerminalError } from '@restatedev/restate-sdk'
|
|
10
11
|
|
|
@@ -34,7 +35,7 @@ describe('cronJobInitiator.create', () => {
|
|
|
34
35
|
|
|
35
36
|
// Every cronJob handler that schedules a next run needs the same "date/objectSendClient/set"
|
|
36
37
|
// wiring; this factory keeps the mocked context consistent across tests.
|
|
37
|
-
function makeSchedulingCtx({ existing } = {}) {
|
|
38
|
+
function makeSchedulingCtx({ existing, now = Date.now(), invocationId } = {}) {
|
|
38
39
|
const sets = []
|
|
39
40
|
const cancelled = []
|
|
40
41
|
return {
|
|
@@ -42,7 +43,8 @@ function makeSchedulingCtx({ existing } = {}) {
|
|
|
42
43
|
cancelled,
|
|
43
44
|
cancel: (invocationId) => cancelled.push(invocationId),
|
|
44
45
|
get: async () => existing,
|
|
45
|
-
date: { now: async () =>
|
|
46
|
+
date: { now: async () => now },
|
|
47
|
+
...(invocationId ? { request: () => ({ id: invocationId }) } : {}),
|
|
46
48
|
key: 'job-1',
|
|
47
49
|
objectSendClient: () => ({
|
|
48
50
|
execute: () => ({ invocationId: Promise.resolve('exec-inv-1') }),
|
|
@@ -109,6 +111,37 @@ describe('cronJob.execute', () => {
|
|
|
109
111
|
expect(ctx.sets.length).toBe(1)
|
|
110
112
|
})
|
|
111
113
|
|
|
114
|
+
test("a run that is not the job's current one does nothing and schedules nothing", async () => {
|
|
115
|
+
const sent = []
|
|
116
|
+
const ctx = makeSchedulingCtx({
|
|
117
|
+
invocationId: 'inv-replaced',
|
|
118
|
+
existing: {
|
|
119
|
+
request: { service: 'Svc', method: 'do', cronExpression: '* * * * *' },
|
|
120
|
+
next_execution_id: 'inv-current',
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
ctx.genericSend = (opts) => sent.push(opts)
|
|
124
|
+
await cronJob.object.execute(ctx)
|
|
125
|
+
expect(sent).toEqual([])
|
|
126
|
+
expect(ctx.sets).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('the current run works and schedules the next one', async () => {
|
|
130
|
+
const sent = []
|
|
131
|
+
const ctx = makeSchedulingCtx({
|
|
132
|
+
invocationId: 'inv-current',
|
|
133
|
+
existing: {
|
|
134
|
+
request: { service: 'Svc', method: 'do', cronExpression: '* * * * *' },
|
|
135
|
+
next_execution_id: 'inv-current',
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
ctx.genericSend = (opts) => sent.push(opts)
|
|
139
|
+
await cronJob.object.execute(ctx)
|
|
140
|
+
expect(sent.length).toBe(1)
|
|
141
|
+
expect(ctx.sets.length).toBe(1)
|
|
142
|
+
expect(ctx.sets[0][1].next_execution_at).toBeNumber()
|
|
143
|
+
})
|
|
144
|
+
|
|
112
145
|
test('throws when there is no job state to execute', async () => {
|
|
113
146
|
const ctx = makeSchedulingCtx({ existing: undefined })
|
|
114
147
|
await expect(cronJob.object.execute(ctx)).rejects.toThrow(TerminalError)
|
|
@@ -179,6 +212,42 @@ describe('cronJob.ensure', () => {
|
|
|
179
212
|
expect(ctx.cancelled).toEqual([])
|
|
180
213
|
})
|
|
181
214
|
|
|
215
|
+
test('the same request with a pending run that is long overdue (lost) schedules a fresh run', async () => {
|
|
216
|
+
const now = Date.parse('2026-10-05T08:00:00Z')
|
|
217
|
+
const existing = {
|
|
218
|
+
request,
|
|
219
|
+
next_execution_time: new Date(now - OVERDUE_GRACE_MS - 1000).toString(),
|
|
220
|
+
next_execution_at: now - OVERDUE_GRACE_MS - 1000,
|
|
221
|
+
next_execution_id: 'inv-killed',
|
|
222
|
+
}
|
|
223
|
+
const ctx = makeSchedulingCtx({ existing, now })
|
|
224
|
+
const state = await cronJob.object.ensure(ctx, request)
|
|
225
|
+
expect(state.next_execution_id).toBe('exec-inv-1')
|
|
226
|
+
expect(state.next_execution_at).toBeGreaterThan(now)
|
|
227
|
+
expect(ctx.cancelled).toEqual(['inv-killed'])
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('a state written before next_execution_at existed is judged by its date string', async () => {
|
|
231
|
+
const now = Date.parse('2026-10-05T08:00:00Z')
|
|
232
|
+
const existing = {
|
|
233
|
+
request,
|
|
234
|
+
next_execution_time: new Date(now - 10 * 60_000).toString(),
|
|
235
|
+
next_execution_id: 'inv-killed',
|
|
236
|
+
}
|
|
237
|
+
const ctx = makeSchedulingCtx({ existing, now })
|
|
238
|
+
expect((await cronJob.object.ensure(ctx, request)).next_execution_id).toBe('exec-inv-1')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('a pending run that is due soon, or only a little late, is left alone', async () => {
|
|
242
|
+
const now = Date.parse('2026-10-05T08:00:00Z')
|
|
243
|
+
for (const dueAt of [now + 30_000, now - OVERDUE_GRACE_MS + 1000]) {
|
|
244
|
+
const existing = { request, next_execution_at: dueAt, next_execution_id: 'inv-pending' }
|
|
245
|
+
const ctx = makeSchedulingCtx({ existing, now })
|
|
246
|
+
expect(await cronJob.object.ensure(ctx, request)).toBe(existing)
|
|
247
|
+
expect(ctx.cancelled).toEqual([])
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
182
251
|
test('unknown fields and field order do not make a request "different"', async () => {
|
|
183
252
|
const existing = { request, next_execution_time: 'T', next_execution_id: 'inv-old' }
|
|
184
253
|
const ctx = makeSchedulingCtx({ existing })
|