@resonatehq/sdk 0.5.3 → 0.6.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.
Files changed (80) hide show
  1. package/dist/core/encoders/json.d.ts.map +1 -1
  2. package/dist/core/encoders/json.js +12 -0
  3. package/dist/core/encoders/json.js.map +1 -1
  4. package/dist/core/errors.d.ts +4 -2
  5. package/dist/core/errors.d.ts.map +1 -1
  6. package/dist/core/errors.js +5 -2
  7. package/dist/core/errors.js.map +1 -1
  8. package/dist/core/options.d.ts +20 -11
  9. package/dist/core/options.d.ts.map +1 -1
  10. package/dist/core/options.js +11 -1
  11. package/dist/core/options.js.map +1 -1
  12. package/dist/core/promises/promises.d.ts +2 -2
  13. package/dist/core/promises/promises.d.ts.map +1 -1
  14. package/dist/core/promises/promises.js +2 -1
  15. package/dist/core/promises/promises.js.map +1 -1
  16. package/dist/core/promises/types.d.ts +10 -83
  17. package/dist/core/promises/types.d.ts.map +1 -1
  18. package/dist/core/promises/types.js +10 -10
  19. package/dist/core/promises/types.js.map +1 -1
  20. package/dist/core/retry.d.ts +36 -26
  21. package/dist/core/retry.d.ts.map +1 -1
  22. package/dist/core/retry.js +130 -14
  23. package/dist/core/retry.js.map +1 -1
  24. package/dist/core/schedules/schedules.d.ts +1 -1
  25. package/dist/core/schedules/schedules.d.ts.map +1 -1
  26. package/dist/core/schedules/schedules.js.map +1 -1
  27. package/dist/core/storages/memory.d.ts.map +1 -1
  28. package/dist/core/storages/memory.js +2 -0
  29. package/dist/core/storages/memory.js.map +1 -1
  30. package/dist/core/storages/withTimeout.d.ts +6 -6
  31. package/dist/core/storages/withTimeout.d.ts.map +1 -1
  32. package/dist/core/storages/withTimeout.js +2 -13
  33. package/dist/core/storages/withTimeout.js.map +1 -1
  34. package/dist/core/store.d.ts +7 -7
  35. package/dist/core/store.d.ts.map +1 -1
  36. package/dist/core/stores/local.d.ts +9 -9
  37. package/dist/core/stores/local.d.ts.map +1 -1
  38. package/dist/core/stores/local.js +2 -2
  39. package/dist/core/stores/local.js.map +1 -1
  40. package/dist/core/stores/remote.d.ts +7 -7
  41. package/dist/core/stores/remote.d.ts.map +1 -1
  42. package/dist/core/stores/remote.js +29 -7
  43. package/dist/core/stores/remote.js.map +1 -1
  44. package/dist/core/utils.d.ts +82 -0
  45. package/dist/core/utils.d.ts.map +1 -1
  46. package/dist/core/utils.js +103 -1
  47. package/dist/core/utils.js.map +1 -1
  48. package/dist/index.d.ts +1 -6
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -11
  51. package/dist/index.js.map +1 -1
  52. package/dist/resonate.d.ts +163 -49
  53. package/dist/resonate.d.ts.map +1 -1
  54. package/dist/resonate.js +626 -146
  55. package/dist/resonate.js.map +1 -1
  56. package/package.json +1 -1
  57. package/dist/async.d.ts +0 -201
  58. package/dist/async.d.ts.map +0 -1
  59. package/dist/async.js +0 -331
  60. package/dist/async.js.map +0 -1
  61. package/dist/core/execution.d.ts +0 -47
  62. package/dist/core/execution.d.ts.map +0 -1
  63. package/dist/core/execution.js +0 -310
  64. package/dist/core/execution.js.map +0 -1
  65. package/dist/core/future.d.ts +0 -56
  66. package/dist/core/future.d.ts.map +0 -1
  67. package/dist/core/future.js +0 -107
  68. package/dist/core/future.js.map +0 -1
  69. package/dist/core/invocation.d.ts +0 -44
  70. package/dist/core/invocation.d.ts.map +0 -1
  71. package/dist/core/invocation.js +0 -103
  72. package/dist/core/invocation.js.map +0 -1
  73. package/dist/core/retries/retry.d.ts +0 -20
  74. package/dist/core/retries/retry.d.ts.map +0 -1
  75. package/dist/core/retries/retry.js +0 -41
  76. package/dist/core/retries/retry.js.map +0 -1
  77. package/dist/generator.d.ts +0 -208
  78. package/dist/generator.d.ts.map +0 -1
  79. package/dist/generator.js +0 -427
  80. package/dist/generator.js.map +0 -1
package/dist/resonate.js CHANGED
@@ -23,43 +23,32 @@ var __importStar = (this && this.__importStar) || function (mod) {
23
23
  return result;
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.ResonateBase = void 0;
26
+ exports.InvocationHandle = exports.Context = exports.Resonate = void 0;
27
27
  const json_1 = require("./core/encoders/json");
28
+ const errors_1 = require("./core/errors");
28
29
  const logger_1 = require("./core/loggers/logger");
29
30
  const options_1 = require("./core/options");
30
- const promises = __importStar(require("./core/promises/promises"));
31
- const retry_1 = require("./core/retries/retry");
31
+ const durablePromises = __importStar(require("./core/promises/promises"));
32
+ const retryPolicies = __importStar(require("./core/retry"));
33
+ const retry_1 = require("./core/retry");
32
34
  const schedules = __importStar(require("./core/schedules/schedules"));
33
35
  const local_1 = require("./core/stores/local");
34
36
  const remote_1 = require("./core/stores/remote");
35
37
  const utils = __importStar(require("./core/utils"));
36
- /////////////////////////////////////////////////////////////////////
37
- // Resonate
38
- /////////////////////////////////////////////////////////////////////
39
- class ResonateBase {
40
- functions = {};
38
+ const utils_1 = require("./core/utils");
39
+ class Resonate {
40
+ #registeredFunctions = {};
41
+ #invocationHandles;
42
+ #interval;
43
+ store;
44
+ logger;
45
+ defaultInvocationOptions;
41
46
  promises;
42
47
  schedules;
43
- pid;
44
- poll;
45
- timeout;
46
- tags;
47
- encoder;
48
- logger;
49
- retry;
50
- store;
51
- interval;
52
48
  constructor({ auth = undefined, encoder = new json_1.JSONEncoder(), heartbeat = 15000, // 15s
53
- logger = new logger_1.Logger(), pid = utils.randomId(), poll = 5000, // 5s
54
- retry = retry_1.Retry.exponential(), store = undefined, tags = {}, timeout = 10000, // 10s
49
+ logger = new logger_1.Logger(), pid = utils.randomId(), pollFrequency = 5000, // 5s
50
+ retryPolicy = retryPolicies.exponential(), store = undefined, tags = {}, timeout = 10000, // 10s
55
51
  url = undefined, } = {}) {
56
- this.encoder = encoder;
57
- this.logger = logger;
58
- this.pid = pid;
59
- this.poll = poll;
60
- this.retry = retry;
61
- this.tags = tags;
62
- this.timeout = timeout;
63
52
  if (store) {
64
53
  this.store = store;
65
54
  }
@@ -79,43 +68,71 @@ class ResonateBase {
79
68
  pid,
80
69
  });
81
70
  }
71
+ this.logger = logger;
72
+ this.#invocationHandles = new Map();
73
+ this.defaultInvocationOptions = {
74
+ __resonate: true,
75
+ durable: true,
76
+ eidFn: utils.randomId,
77
+ encoder: encoder,
78
+ idempotencyKeyFn: utils.hash,
79
+ shouldLock: undefined,
80
+ pollFrequency,
81
+ retryPolicy,
82
+ tags,
83
+ timeout,
84
+ version: 0,
85
+ };
82
86
  // promises
83
87
  this.promises = {
84
- create: (id, timeout, opts = {}) => promises.DurablePromise.create(this.store.promises, this.encoder, id, timeout, opts),
85
- resolve: (id, value, opts = {}) => promises.DurablePromise.resolve(this.store.promises, this.encoder, id, value, opts),
86
- reject: (id, error, opts = {}) => promises.DurablePromise.reject(this.store.promises, this.encoder, id, error, opts),
87
- cancel: (id, error, opts = {}) => promises.DurablePromise.cancel(this.store.promises, this.encoder, id, error, opts),
88
- get: (id) => promises.DurablePromise.get(this.store.promises, this.encoder, id),
89
- search: (id, state, tags, limit) => promises.DurablePromise.search(this.store.promises, this.encoder, id, state, tags, limit),
88
+ create: (id, timeout, opts = {}) => durablePromises.DurablePromise.create(this.promisesStore, encoder, id, timeout, opts),
89
+ resolve: (id, value, opts = {}) => durablePromises.DurablePromise.resolve(this.promisesStore, encoder, id, value, opts),
90
+ reject: (id, error, opts = {}) => durablePromises.DurablePromise.reject(this.promisesStore, encoder, id, error, opts),
91
+ cancel: (id, error, opts = {}) => durablePromises.DurablePromise.cancel(this.promisesStore, encoder, id, error, opts),
92
+ get: (id) => durablePromises.DurablePromise.get(this.promisesStore, encoder, id),
93
+ search: (id, state, tags, limit) => durablePromises.DurablePromise.search(this.promisesStore, encoder, id, state, tags, limit),
90
94
  };
91
95
  // schedules
92
96
  this.schedules = {
93
- create: (id, cron, promiseId, promiseTimeout, opts = {}) => schedules.Schedule.create(this.store.schedules, this.encoder, id, cron, promiseId, promiseTimeout, opts),
94
- get: (id) => schedules.Schedule.get(this.store.schedules, this.encoder, id),
95
- search: (id, tags, limit) => schedules.Schedule.search(this.store.schedules, this.encoder, id, tags, limit),
97
+ create: (id, cron, promiseId, promiseTimeout, opts = {}) => schedules.Schedule.create(this.store.schedules, encoder, id, cron, promiseId, promiseTimeout, opts),
98
+ get: (id) => schedules.Schedule.get(this.store.schedules, encoder, id),
99
+ search: (id, tags, limit) => schedules.Schedule.search(this.store.schedules, encoder, id, tags, limit),
96
100
  };
97
101
  }
102
+ get promisesStore() {
103
+ return this.store.promises;
104
+ }
105
+ get locksStore() {
106
+ return this.store.locks;
107
+ }
108
+ registeredFunction(funcName, version) {
109
+ if (!this.#registeredFunctions[funcName]?.[version]) {
110
+ throw new Error(`Function ${funcName} version ${version} not registered`);
111
+ }
112
+ return this.#registeredFunctions[funcName][version];
113
+ }
98
114
  register(name, func, opts = {}) {
99
115
  // set default version
100
116
  opts.version = opts.version ?? 1;
101
- // set default options
102
- const options = this.defaults(opts);
117
+ // set default values for the options options
118
+ const options = this.withDefaultOpts(opts);
103
119
  if (options.version <= 0) {
104
120
  throw new Error("Version must be greater than 0");
105
121
  }
106
- if (!this.functions[name]) {
107
- this.functions[name] = {};
122
+ if (!this.#registeredFunctions[name]) {
123
+ this.#registeredFunctions[name] = {};
108
124
  }
109
- if (this.functions[name][options.version]) {
125
+ if (this.#registeredFunctions[name][options.version]) {
110
126
  throw new Error(`Function ${name} version ${options.version} already registered`);
111
127
  }
112
- // register as latest (0) if version is greatest so far
113
- if (options.version > Math.max(...Object.values(this.functions[name]).map((f) => f.opts.version))) {
114
- this.functions[name][0] = { func, opts: options };
128
+ // Get the highest version number of existing functions with this name
129
+ const latestVersion = Math.max(...Object.values(this.#registeredFunctions[name]).map((f) => f.opts.version));
130
+ // If the new function's version is higher, register it as the latest (index 0)
131
+ if (options.version > latestVersion) {
132
+ this.#registeredFunctions[name][0] = { func, opts: options };
115
133
  }
116
134
  // register specific version
117
- this.functions[name][options.version] = { func, opts: options };
118
- return (id, ...args) => this.run(name, id, ...args, options);
135
+ this.#registeredFunctions[name][options.version] = { func, opts: options };
119
136
  }
120
137
  registerModule(module, opts = {}) {
121
138
  for (const key in module) {
@@ -123,50 +140,209 @@ class ResonateBase {
123
140
  }
124
141
  }
125
142
  /**
126
- * Run a Resonate function. Functions must first be registered with {@link register}.
143
+ * Start the resonate service which continually checks for pending promises
144
+ * every `delay` ms.
127
145
  *
128
- * @template T The return type of the function.
129
- * @param id A unique id for the function invocation.
130
- * @param name The function name.
131
- * @param argsWithOpts The function arguments.
132
- * @returns A promise that resolve to the function return value.
146
+ * @param delay Frequency in ms to check for pending promises.
133
147
  */
134
- run(name, id, ...argsWithOpts) {
135
- const { args, opts: { version }, part: { durable, idempotencyKey, tags, timeout }, } = this.split(argsWithOpts);
136
- if (!this.functions[name] || !this.functions[name][version]) {
137
- throw new Error(`Function ${name} version ${version} not registered`);
138
- }
139
- // the options registered with the function are the defaults
140
- const { func, opts: defaults } = this.functions[name][version];
141
- // only the following options can be overridden, this information is persisted
142
- // in the durable promise and therefore not required on the recovery path
143
- const override = {};
144
- if (durable !== undefined) {
145
- override.durable = durable;
146
- }
147
- if (idempotencyKey !== undefined) {
148
- override.idempotencyKey = idempotencyKey;
149
- }
150
- if (tags !== undefined) {
151
- override.tags = { ...defaults.tags, ...tags, "resonate:invocation": "true" };
148
+ async start(delay = 5000) {
149
+ clearInterval(this.#interval);
150
+ this.#_start();
151
+ this.#interval = setInterval(this.#_start.bind(this), delay);
152
+ }
153
+ /**
154
+ * Stop the resonate service.
155
+ */
156
+ async stop() {
157
+ clearInterval(this.#interval);
158
+ }
159
+ async #_start() {
160
+ try {
161
+ for await (const promises of this.promisesStore.search("*", "pending", { "resonate:invocation": "true" })) {
162
+ for (const promiseRecord of promises) {
163
+ const param = this.defaultInvocationOptions.encoder.decode(promiseRecord.param.data);
164
+ if (param &&
165
+ typeof param === "object" &&
166
+ "func" in param &&
167
+ typeof param.func === "string" &&
168
+ "version" in param &&
169
+ typeof param.version === "number" &&
170
+ "args" in param &&
171
+ Array.isArray(param.args) &&
172
+ "retryPolicy" in param &&
173
+ retryPolicies.isRetryPolicy(param.retryPolicy)) {
174
+ // Since the promise is already created on the server, we should use that idempotencyKey.
175
+ // If for whatever reason it is not, we should recalculate it using our defaults.
176
+ const idempotencyKeyFn = (_) => {
177
+ return (promiseRecord.idempotencyKeyForCreate ??
178
+ this.defaultInvocationOptions.idempotencyKeyFn(promiseRecord.id));
179
+ };
180
+ await this.invokeLocal(param.func, promiseRecord.id, ...param.args, (0, options_1.options)({
181
+ retryPolicy: param.retryPolicy,
182
+ version: param.version,
183
+ idempotencyKeyFn,
184
+ }));
185
+ }
186
+ }
187
+ }
152
188
  }
153
- else {
154
- override.tags = { ...defaults.tags, "resonate:invocation": "true" };
189
+ catch (e) {
190
+ // squash all errors and log,
191
+ // transient errors will be ironed out in the next interval
192
+ this.logger.error(e);
155
193
  }
156
- if (timeout !== undefined) {
157
- override.timeout = timeout;
194
+ }
195
+ async run(name, id, ...argsWithOverrides) {
196
+ const handle = await this.invokeLocal(name, id, ...argsWithOverrides);
197
+ return await handle.result();
198
+ }
199
+ async invokeLocal(name, id, ...argsWithOverrides) {
200
+ if (this.#invocationHandles.has(id)) {
201
+ return this.#invocationHandles.get(id);
158
202
  }
159
- // merge defaults with override to get opts
160
- const opts = {
161
- ...defaults,
162
- ...override,
163
- };
203
+ const { args, opts: optionOverrides } = utils.split(argsWithOverrides);
204
+ // version 0 means the latest registered version
205
+ const givenVersion = optionOverrides?.version ?? 0;
206
+ // guarantees we only use the overrides in case the user pass an object with more properties
207
+ const { eidFn, idempotencyKeyFn, retryPolicy, tags, timeout, version } = optionOverrides;
208
+ const { func, opts: registeredOpts } = this.registeredFunction(name, givenVersion);
209
+ const opts = utils.mergeObjects({
210
+ eidFn,
211
+ idempotencyKeyFn,
212
+ retryPolicy,
213
+ tags,
214
+ timeout,
215
+ version,
216
+ }, registeredOpts);
217
+ // We want to preserve the registered version.
218
+ opts.version = registeredOpts.version;
219
+ // For tags we need to merge the objects themselves
220
+ // giving priority to the passed tags and add the
221
+ // resonate:invocation tag to identify a top level invocation
222
+ opts.tags = { ...registeredOpts.tags, ...tags, "resonate:invocation": "true" };
164
223
  // lock on top level is true by default
165
- opts.lock = opts.lock ?? true;
166
- return this.execute(name, id, func, args, opts, defaults);
224
+ opts.shouldLock = opts.shouldLock ?? true;
225
+ const param = {
226
+ func: name,
227
+ version: opts.version,
228
+ retryPolicy: opts.retryPolicy,
229
+ args,
230
+ };
231
+ const idempotencyKey = opts.idempotencyKeyFn(id);
232
+ const storedPromise = await this.promisesStore.create(id, idempotencyKey, false, undefined, opts.encoder.encode(param), Date.now() + opts.timeout, opts.tags);
233
+ const runFunc = async () => {
234
+ const eid = opts.eidFn(id);
235
+ let value;
236
+ // If the promise that comes back from the server is already completed, resolve or reject right away.
237
+ switch (storedPromise.state) {
238
+ case "RESOLVED":
239
+ return opts.encoder.decode(storedPromise.value.data);
240
+ case "REJECTED":
241
+ throw opts.encoder.decode(storedPromise.value.data);
242
+ case "REJECTED_CANCELED":
243
+ throw new errors_1.ResonateError("Resonate function canceled", errors_1.ErrorCodes.CANCELED, opts.encoder.decode(storedPromise.value.data));
244
+ case "REJECTED_TIMEDOUT":
245
+ throw new errors_1.ResonateError(`Resonate function timedout at ${new Date(storedPromise.timeout).toISOString()}`, errors_1.ErrorCodes.TIMEDOUT);
246
+ }
247
+ // storedPromise.state === "PENDING"
248
+ try {
249
+ // Acquire the lock if necessary
250
+ if (opts.shouldLock) {
251
+ const acquireLock = async () => {
252
+ try {
253
+ return await this.store.locks.tryAcquire(id, eid);
254
+ }
255
+ catch (e) {
256
+ // if lock is already acquired, return false so we can poll
257
+ if (e instanceof errors_1.ResonateError && e.code === errors_1.ErrorCodes.STORE_FORBIDDEN) {
258
+ return false;
259
+ }
260
+ throw e;
261
+ }
262
+ };
263
+ while (!(await acquireLock())) {
264
+ await (0, utils_1.sleep)(opts.pollFrequency);
265
+ }
266
+ }
267
+ let error;
268
+ const ctx = Context.createRootContext(this, { id, eid, name, opts });
269
+ // we need to hold on to a boolean to determine if the function was successful,
270
+ // we cannot rely on the value or error as these values could be undefined
271
+ let success = true;
272
+ try {
273
+ value = await (0, retry_1.runWithRetry)(async () => {
274
+ ctx.reset();
275
+ return await func(ctx, ...args);
276
+ }, opts.retryPolicy, storedPromise.timeout);
277
+ }
278
+ catch (e) {
279
+ if (ctx.aborted) {
280
+ throw e;
281
+ }
282
+ // We need to capture the error to be able to reject the durable promise
283
+ // after that we will then propagate this error by rejecting the result promise
284
+ error = e;
285
+ success = false;
286
+ }
287
+ finally {
288
+ await ctx.finalize();
289
+ }
290
+ let completedPromiseRecord;
291
+ if (success) {
292
+ completedPromiseRecord = await this.promisesStore.resolve(id, idempotencyKey, false, storedPromise.value.headers, opts.encoder.encode(value));
293
+ }
294
+ else {
295
+ completedPromiseRecord = await this.promisesStore.reject(id, idempotencyKey, false, storedPromise.value.headers, opts.encoder.encode(error));
296
+ }
297
+ // Because of eventual consistency and recovery paths it is possible that we get a
298
+ // rejected promise even if we did call `resolve` on it or the other way around.
299
+ // What should never happen is that we get a "PENDING" promise
300
+ switch (completedPromiseRecord.state) {
301
+ case "RESOLVED":
302
+ return value;
303
+ case "REJECTED":
304
+ throw error;
305
+ case "REJECTED_CANCELED":
306
+ throw new errors_1.ResonateError("Resonate function canceled", errors_1.ErrorCodes.CANCELED, error);
307
+ case "REJECTED_TIMEDOUT":
308
+ throw new errors_1.ResonateError(`Resonate function timedout at ${new Date(completedPromiseRecord.timeout).toISOString()}`, errors_1.ErrorCodes.TIMEDOUT);
309
+ case "PENDING":
310
+ throw new Error("Unreachable");
311
+ }
312
+ }
313
+ catch (err) {
314
+ if (err instanceof errors_1.ResonateError && (err.code === errors_1.ErrorCodes.CANCELED || err.code === errors_1.ErrorCodes.TIMEDOUT)) {
315
+ // Cancel and timeout errors just forward them
316
+ throw err;
317
+ }
318
+ else if (err instanceof errors_1.ResonateError && err.code !== errors_1.ErrorCodes.ABORT) {
319
+ // All other resonate errors we wrap them in an abort.
320
+ throw new errors_1.ResonateError("Unrecoverable Error: Aborting", errors_1.ErrorCodes.ABORT, err);
321
+ }
322
+ else {
323
+ // - The error was already an abort error, for example, the unrecoverable error happened in one
324
+ // of the children invocations
325
+ // - Error in the user function.
326
+ // Either way, just forward it.
327
+ throw err;
328
+ }
329
+ }
330
+ finally {
331
+ // release lock if necessary
332
+ if (opts.shouldLock) {
333
+ await this.locksStore.release(id, eid);
334
+ }
335
+ }
336
+ return value;
337
+ };
338
+ const resultPromise = runFunc();
339
+ const handle = new InvocationHandle(resultPromise, id);
340
+ this.#invocationHandles.set(id, handle);
341
+ return handle;
167
342
  }
168
- schedule(name, cron, func, ...argsWithOpts) {
169
- const { args, opts } = this.split(argsWithOpts);
343
+ async schedule(name, cron, func, ...argsWithOpts) {
344
+ const { args, opts: givenOpts } = utils.split(argsWithOpts);
345
+ const opts = this.withDefaultOpts(givenOpts);
170
346
  if (typeof func === "function") {
171
347
  // if function is provided, the default version is 1
172
348
  // as opposed to 0 (alias for latest version)
@@ -174,94 +350,398 @@ class ResonateBase {
174
350
  this.register(name, func, opts);
175
351
  }
176
352
  const funcName = typeof func === "string" ? func : name;
177
- if (!this.functions[funcName] || !this.functions[funcName][opts.version]) {
178
- throw new Error(`Function ${funcName} version ${opts.version} not registered`);
179
- }
180
- const { opts: { version, timeout, tags: promiseTags }, } = this.functions[funcName][opts.version];
181
- const idempotencyKey = typeof opts.idempotencyKey === "function" ? opts.idempotencyKey(funcName) : opts.idempotencyKey;
353
+ const { opts: { retryPolicy: retry, version, timeout, tags: promiseTags }, } = this.registeredFunction(funcName, opts.version);
354
+ const idempotencyKey = opts.idempotencyKeyFn(funcName);
182
355
  const promiseParam = {
183
356
  func: funcName,
184
357
  version,
358
+ retryPolicy: retry,
185
359
  args,
186
360
  };
187
- return this.schedules.create(name, cron, "{{.id}}.{{.timestamp}}", timeout, {
361
+ return await this.schedules.create(name, cron, "{{.id}}.{{.timestamp}}", timeout, {
188
362
  idempotencyKey,
189
363
  promiseParam,
190
364
  promiseTags,
191
365
  });
192
366
  }
367
+ withDefaultOpts(givenOpts = {}) {
368
+ // merge tags
369
+ const tags = { ...this.defaultInvocationOptions.tags, ...givenOpts.tags };
370
+ return {
371
+ ...this.defaultInvocationOptions,
372
+ ...givenOpts,
373
+ tags,
374
+ };
375
+ }
376
+ }
377
+ exports.Resonate = Resonate;
378
+ class Context {
379
+ #resonate;
380
+ #stopAllPolling = false;
381
+ #invocationHandles;
382
+ #aborted;
383
+ #abortCause;
384
+ childrenCount;
385
+ invocationData;
386
+ parent;
387
+ root;
388
+ constructor(resonate, invocationData, parent) {
389
+ this.#resonate = resonate;
390
+ this.#invocationHandles = new Map();
391
+ this.#aborted = false;
392
+ this.parent = parent;
393
+ this.root = !parent ? this : parent.root;
394
+ this.invocationData = invocationData;
395
+ this.childrenCount = 0;
396
+ }
397
+ static createRootContext(resonate, invocationData) {
398
+ return new Context(resonate, invocationData, undefined);
399
+ }
400
+ static createChildrenContext(parentCtx, invocationData) {
401
+ return new Context(parentCtx.#resonate, invocationData, parentCtx);
402
+ }
403
+ reset() {
404
+ this.childrenCount = 0;
405
+ }
406
+ async finalize() {
407
+ await Promise.allSettled(Array.from(this.#invocationHandles, ([_, handle]) => handle.result()));
408
+ }
409
+ abort(cause) {
410
+ this.root.#aborted = true;
411
+ this.root.#abortCause = cause;
412
+ }
413
+ get aborted() {
414
+ return this.#aborted;
415
+ }
416
+ get abortCause() {
417
+ return this.#abortCause;
418
+ }
419
+ async run(funcOrId, ...argsWithOpts) {
420
+ let handle;
421
+ if (typeof funcOrId === "string") {
422
+ handle = await this.invokeRemote(funcOrId, ...argsWithOpts);
423
+ }
424
+ else {
425
+ handle = await this.invokeLocal(funcOrId, ...argsWithOpts);
426
+ }
427
+ return await handle.result();
428
+ }
429
+ async invokeRemote(funcId, ...argsWithOpts) {
430
+ if (this.#invocationHandles.has(funcId)) {
431
+ return this.#invocationHandles.get(funcId);
432
+ }
433
+ const { opts: givenOpts } = utils.split(argsWithOpts);
434
+ const { opts: registeredOpts } = this.#resonate.registeredFunction(this.root.invocationData.name, this.root.invocationData.opts.version);
435
+ const opts = { ...registeredOpts, ...givenOpts };
436
+ // Merge the tags
437
+ opts.tags = { ...registeredOpts.tags, ...givenOpts?.tags };
438
+ // Default lock is false for children execution
439
+ opts.shouldLock = opts.shouldLock ?? false;
440
+ // Children execution do not need params since we don't go trough the recovery path with children
441
+ const param = {};
442
+ const idempotencyKey = opts.idempotencyKeyFn(funcId);
443
+ const storedPromise = await this.#resonate.promisesStore.create(funcId, idempotencyKey, false, undefined, opts.encoder.encode(param), Date.now() + opts.timeout, opts.tags);
444
+ const runFunc = async () => {
445
+ while (!this.#stopAllPolling) {
446
+ const durablePromiseRecord = await this.#resonate.promisesStore.get(storedPromise.id);
447
+ switch (durablePromiseRecord.state) {
448
+ case "RESOLVED":
449
+ return opts.encoder.decode(durablePromiseRecord.value.data);
450
+ case "REJECTED":
451
+ throw opts.encoder.decode(durablePromiseRecord.value.data);
452
+ case "REJECTED_CANCELED":
453
+ throw new errors_1.ResonateError("Resonate function canceled", errors_1.ErrorCodes.CANCELED, opts.encoder.decode(durablePromiseRecord.value.data));
454
+ case "REJECTED_TIMEDOUT":
455
+ throw new errors_1.ResonateError(`Resonate function timedout at ${new Date(durablePromiseRecord.timeout).toISOString()}`, errors_1.ErrorCodes.TIMEDOUT);
456
+ case "PENDING":
457
+ break;
458
+ }
459
+ // TODO: Consider using exponential backoff instead.
460
+ (0, utils_1.sleep)(opts.pollFrequency);
461
+ }
462
+ throw new Error(`Polling of remote invocation with ${funcId} was stopped`);
463
+ };
464
+ const resultPromise = runFunc();
465
+ const invocationHandle = new InvocationHandle(resultPromise, funcId);
466
+ this.#invocationHandles.set(funcId, invocationHandle);
467
+ return invocationHandle;
468
+ }
469
+ async invokeLocal(func, ...argsWithOpts) {
470
+ const { args, opts: givenOpts } = utils.split(argsWithOpts);
471
+ const { opts: registeredOpts } = this.#resonate.registeredFunction(this.root.invocationData.name, this.root.invocationData.opts.version);
472
+ const opts = { ...registeredOpts, ...givenOpts };
473
+ // Merge the tags
474
+ opts.tags = { ...registeredOpts.tags, ...givenOpts.tags };
475
+ // Default lock is false for children execution
476
+ opts.shouldLock = opts.shouldLock ?? false;
477
+ this.childrenCount++;
478
+ // If it is an anonymous function give at anon name nested within the current invocation name
479
+ const name = func.name ? func.name : `${this.invocationData.name}__anon${this.childrenCount}`;
480
+ const id = `${this.invocationData.id}.${this.childrenCount}.${name}`;
481
+ if (this.#invocationHandles.has(id)) {
482
+ return this.#invocationHandles.get(id);
483
+ }
484
+ if (!opts.durable) {
485
+ const eid = opts.eidFn(id);
486
+ const runFunc = async () => {
487
+ const ctx = Context.createChildrenContext(this, { name, id, eid, opts });
488
+ const timeout = Date.now() + opts.timeout;
489
+ return (await (0, retry_1.runWithRetry)(async () => {
490
+ ctx.reset();
491
+ return await func(ctx, ...args);
492
+ }, opts.retryPolicy, timeout));
493
+ };
494
+ const resultPromise = runFunc();
495
+ const handle = new InvocationHandle(resultPromise, id);
496
+ this.#invocationHandles.set(id, handle);
497
+ return handle;
498
+ }
499
+ // Children execution do not need params since we don't go trough the recovery path with children
500
+ const param = {};
501
+ const idempotencyKey = opts.idempotencyKeyFn(id);
502
+ const storedPromise = await this.#resonate.promisesStore.create(id, idempotencyKey, false, undefined, opts.encoder.encode(param), Date.now() + opts.timeout, opts.tags);
503
+ const runFunc = async () => {
504
+ const eid = opts.eidFn(id);
505
+ let value;
506
+ // If the promise that comes back from the server is already completed, resolve or reject right away.
507
+ switch (storedPromise.state) {
508
+ case "RESOLVED":
509
+ return opts.encoder.decode(storedPromise.value.data);
510
+ case "REJECTED":
511
+ throw opts.encoder.decode(storedPromise.value.data);
512
+ case "REJECTED_CANCELED":
513
+ throw new errors_1.ResonateError("Resonate function canceled", errors_1.ErrorCodes.CANCELED, opts.encoder.decode(storedPromise.value.data));
514
+ case "REJECTED_TIMEDOUT":
515
+ throw new errors_1.ResonateError(`Resonate function timedout at ${new Date(storedPromise.timeout).toISOString()}`, errors_1.ErrorCodes.TIMEDOUT);
516
+ }
517
+ // storedPromise.state === "PENDING"
518
+ try {
519
+ // Acquire the lock if necessary
520
+ if (opts.shouldLock) {
521
+ const acquireLock = async () => {
522
+ try {
523
+ return await this.#resonate.locksStore.tryAcquire(id, eid);
524
+ }
525
+ catch (e) {
526
+ // if lock is already acquired, return false so we can poll
527
+ if (e instanceof errors_1.ResonateError && e.code === errors_1.ErrorCodes.STORE_FORBIDDEN) {
528
+ return false;
529
+ }
530
+ throw e;
531
+ }
532
+ };
533
+ while (!(await acquireLock())) {
534
+ await (0, utils_1.sleep)(opts.pollFrequency);
535
+ }
536
+ }
537
+ let error;
538
+ const ctx = Context.createChildrenContext(this, { id, eid, name, opts });
539
+ // we need to hold on to a boolean to determine if the function was successful,
540
+ // we cannot rely on the value or error as these values could be undefined
541
+ let success = true;
542
+ try {
543
+ value = await (0, retry_1.runWithRetry)(async () => {
544
+ ctx.reset();
545
+ return await func(ctx, ...args);
546
+ }, opts.retryPolicy, storedPromise.timeout);
547
+ }
548
+ catch (e) {
549
+ // We need to capture the error to be able to reject the durable promise,
550
+ // after that we will then propagate this error by rejecting the result promise
551
+ error = e;
552
+ success = false;
553
+ }
554
+ finally {
555
+ // Resonate will implicitly await all the invocationHandles as the last
556
+ // thing it does with the context before it goes out of scope
557
+ await ctx.finalize();
558
+ }
559
+ if (this.root.aborted) {
560
+ throw new errors_1.ResonateError("Unrecoverable Error: Aborting", errors_1.ErrorCodes.ABORT, this.root.abortCause);
561
+ }
562
+ let completedPromiseRecord;
563
+ if (success) {
564
+ completedPromiseRecord = await this.#resonate.promisesStore.resolve(id, idempotencyKey, false, storedPromise.value.headers, opts.encoder.encode(value));
565
+ }
566
+ else {
567
+ completedPromiseRecord = await this.#resonate.promisesStore.reject(id, idempotencyKey, false, storedPromise.value.headers, opts.encoder.encode(error));
568
+ }
569
+ // Because of eventual consistency and recovery paths it is possible that we get a
570
+ // rejected promise even if we did call `resolve` on it or the other way around.
571
+ // What should never happen is that we get a "PENDING" promise
572
+ switch (completedPromiseRecord.state) {
573
+ case "RESOLVED":
574
+ return value;
575
+ case "REJECTED":
576
+ throw error;
577
+ case "REJECTED_CANCELED":
578
+ throw new errors_1.ResonateError("Resonate function canceled", errors_1.ErrorCodes.CANCELED, error);
579
+ case "REJECTED_TIMEDOUT":
580
+ throw new errors_1.ResonateError(`Resonate function timedout at ${new Date(completedPromiseRecord.timeout).toISOString()}`, errors_1.ErrorCodes.TIMEDOUT);
581
+ case "PENDING":
582
+ throw new Error("Unreachable");
583
+ }
584
+ }
585
+ catch (err) {
586
+ if (err instanceof errors_1.ResonateError && (err.code === errors_1.ErrorCodes.CANCELED || err.code === errors_1.ErrorCodes.TIMEDOUT)) {
587
+ // Cancel and timeout errors, just forward them
588
+ throw err;
589
+ }
590
+ else if (err instanceof errors_1.ResonateError && err.code !== errors_1.ErrorCodes.ABORT) {
591
+ // Any other instance of ResonateError we must abort the current execution.
592
+ this.abort(err);
593
+ throw new errors_1.ResonateError("Unrecoverable Error: Aborting", errors_1.ErrorCodes.ABORT, err);
594
+ }
595
+ else {
596
+ throw err;
597
+ }
598
+ }
599
+ finally {
600
+ // release lock if necessary
601
+ if (opts.shouldLock) {
602
+ await this.#resonate.locksStore.release(id, eid);
603
+ }
604
+ }
605
+ return value;
606
+ };
607
+ const resultPromise = runFunc();
608
+ const invocationHandle = new InvocationHandle(resultPromise, id);
609
+ this.#invocationHandles.set(id, invocationHandle);
610
+ return invocationHandle;
611
+ }
193
612
  /**
194
- * Construct options.
613
+ * Durable version of sleep.
614
+ * Sleep for the specified time (ms).
195
615
  *
196
- * @param opts A partial {@link Options} object.
197
- * @returns PartialOptions.
616
+ * @param ms Amount of time to sleep in milliseconds.
617
+ * @returns A Promise that resolves after the specified time has elapsed.
198
618
  */
199
- options(opts = {}) {
200
- return { ...opts, __resonate: true };
619
+ async sleep(ms) {
620
+ const id = `${this.invocationData.id}.${this.childrenCount++}`;
621
+ const handle = await this.invokeRemote(id, (0, options_1.options)({ timeout: ms, pollFrequency: ms, tags: { "resonate:timeout": "true" }, durable: true }));
622
+ await handle.result();
201
623
  }
202
624
  /**
203
- * Start the resonate service.
625
+ * Creates a Promise that is resolved with an array of results when all of the provided Promises
626
+ * resolve, or rejected when any Promise is rejected.
204
627
  *
205
- * @param delay Frequency in ms to check for pending promises.
628
+ * @param values An array of Promises.
629
+ * @param opts Optional {@link options}.
630
+ * @returns A new ResonatePromise.
206
631
  */
207
- start(delay = 5000) {
208
- clearInterval(this.interval);
209
- this.interval = setInterval(() => this._start(), delay);
632
+ all(values, opts = {}) {
633
+ // catch all promises to prevent unhandled promise rejections,
634
+ // since Promise.all will not be called in the case where the
635
+ // durable promise already completed
636
+ for (const v of values) {
637
+ if (v instanceof Promise)
638
+ v.catch(() => { });
639
+ }
640
+ // prettier-ignore
641
+ return this.run(() => Promise.all(values), (0, options_1.options)({
642
+ retryPolicy: retryPolicies.never(),
643
+ ...opts,
644
+ }));
210
645
  }
211
646
  /**
212
- * Stop the resonate service.
647
+ * Creates a Promise that is fulfilled by the first given promise to be fulfilled, or rejected
648
+ * with an AggregateError.
649
+ *
650
+ * @param values An array of Promises.
651
+ * @param opts Optional {@link options}.
652
+ * @returns A new ResonatePromise.
213
653
  */
214
- stop() {
215
- clearInterval(this.interval);
216
- }
217
- defaults({ durable = true, eid = utils.randomId, encoder = this.encoder, idempotencyKey = utils.hash, lock = undefined, poll = this.poll, retry = this.retry, tags = {}, timeout = this.timeout, version = 0, } = {}) {
218
- // merge tags
219
- tags = { ...this.tags, ...tags };
220
- return {
221
- __resonate: true,
222
- eid,
223
- durable,
224
- encoder,
225
- idempotencyKey,
226
- lock,
227
- poll,
228
- retry,
229
- tags,
230
- timeout,
231
- version,
232
- };
654
+ any(values, opts = {}) {
655
+ // catch all promises to prevent unhandled promise rejections,
656
+ // since Promise.any will not be called in the case where the
657
+ // durable promise already completed
658
+ for (const v of values) {
659
+ if (v instanceof Promise)
660
+ v.catch(() => { });
661
+ }
662
+ // prettier-ignore
663
+ return this.run(() => Promise.any(values), (0, options_1.options)({
664
+ retryPolicy: retryPolicies.never(),
665
+ ...opts,
666
+ }));
233
667
  }
234
- async _start() {
235
- try {
236
- for await (const promises of this.promises.search("*", "pending", { "resonate:invocation": "true" })) {
237
- for (const promise of promises) {
238
- const param = promise.param();
239
- if (param &&
240
- typeof param === "object" &&
241
- "func" in param &&
242
- typeof param.func === "string" &&
243
- "version" in param &&
244
- typeof param.version === "number" &&
245
- "args" in param &&
246
- Array.isArray(param.args)) {
247
- const { func, opts } = this.functions[param.func][param.version];
248
- this.execute(param.func, promise.id, func, param.args, opts, opts, promise);
249
- }
250
- }
251
- }
668
+ /**
669
+ * Creates a Promise that is resolved or rejected when any of the provided Promises are resolved
670
+ * or rejected.
671
+ *
672
+ * @param values An array of Promises.
673
+ * @param opts Optional {@link options}.
674
+ * @returns A new ResonatePromise.
675
+ */
676
+ race(values, opts = {}) {
677
+ // catch all promises to prevent unhandled promise rejections,
678
+ // since Promise.race will not be called in the case where the
679
+ // durable promise already completed
680
+ for (const v of values) {
681
+ if (v instanceof Promise)
682
+ v.catch(() => { });
252
683
  }
253
- catch (e) {
254
- // squash all errors and log,
255
- // transient errors will be ironed out in the next interval
256
- this.logger.error(e);
684
+ // prettier-ignore
685
+ return this.run(() => Promise.race(values), (0, options_1.options)({
686
+ retryPolicy: retryPolicies.never(),
687
+ ...opts,
688
+ }));
689
+ }
690
+ /**
691
+ * Creates a Promise that is resolved with an array of results when all of the provided Promises
692
+ * resolve or reject.
693
+ *
694
+ * @param values An array of Promises.
695
+ * @param opts Optional {@link options}.
696
+ * @returns A new Promise.
697
+ */
698
+ allSettled(values, opts = {}) {
699
+ // catch all promises to prevent unhandled promise rejections,
700
+ // since Promise.allSettled will not be called in the case where the
701
+ // durable promise already completed
702
+ for (const v of values) {
703
+ if (v instanceof Promise)
704
+ v.catch(() => { });
257
705
  }
706
+ // prettier-ignore
707
+ return this.run(() => Promise.allSettled(values), (0, options_1.options)({
708
+ retryPolicy: retryPolicies.never(),
709
+ ...opts,
710
+ }));
711
+ }
712
+ /**
713
+ * Invoke a Resonate function in detached mode. Functions must first be registered with Resonate.
714
+ * a detached invocation will not be implecitly awaited at the end of the current context, instead
715
+ * it will be "supervised" as a top level invocation.
716
+ *
717
+ * @template R The return type of the function.
718
+ * @param id A unique id for the function invocation.
719
+ * @param name The function name.
720
+ * @param argsWithOverrides The function arguments and options overrides.
721
+ * @returns A Res.
722
+ */
723
+ async detached(name, id, ...argsWithOverrides) {
724
+ return await this.#resonate.invokeLocal(name, id, ...argsWithOverrides);
725
+ }
726
+ }
727
+ exports.Context = Context;
728
+ class InvocationHandle {
729
+ resultPromise;
730
+ invocationId;
731
+ constructor(resultPromise, invocationId) {
732
+ this.resultPromise = resultPromise;
733
+ this.invocationId = invocationId;
734
+ }
735
+ /**
736
+ * get the current state of the resultPromise.
737
+ *
738
+ */
739
+ async state() {
740
+ return await utils.promiseState(this.resultPromise);
258
741
  }
259
- split(args) {
260
- const part = args[args.length - 1];
261
- return (0, options_1.isOptions)(part)
262
- ? { args: args.slice(0, -1), opts: this.defaults(part), part }
263
- : { args, opts: this.defaults(), part: {} };
742
+ async result() {
743
+ return this.resultPromise;
264
744
  }
265
745
  }
266
- exports.ResonateBase = ResonateBase;
746
+ exports.InvocationHandle = InvocationHandle;
267
747
  //# sourceMappingURL=resonate.js.map