@izara_project/izara-core-library-asynchronous-flow 1.0.19 → 1.0.21

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/index.js CHANGED
@@ -15,10 +15,14 @@ You should have received a copy of the GNU Affero General Public License
15
15
  along with this program. If not, see <http://www.gnu.org/licenses/>.
16
16
  */
17
17
 
18
- 'use strict';
19
-
20
18
  // Import the Asynchronous Flow shared library functions
21
- import asyncFlowSharedLib from './src/AsyncFlowSharedLib.js';
19
+ import * as asyncFlowSharedLib from './src/asyncFlowSharedLib.js';
20
+ import * as awaitingStep from './src/awaitingStep.js';
21
+ import * as awaitingMultipleSteps from './src/awaitingMultipleSteps.js';
22
22
 
23
- // Export all the functions
24
- export default asyncFlowSharedLib;
23
+ // Combine all functions from the imported modules into a single export object.
24
+ export default {
25
+ ...asyncFlowSharedLib,
26
+ ...awaitingStep,
27
+ ...awaitingMultipleSteps
28
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@izara_project/izara-core-library-asynchronous-flow",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "Shared asynchronous flow logic",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,507 @@
1
+ /*
2
+ Copyright (C) 2021 Sven Mason <http://izara.io>
3
+
4
+ This program is free software: you can redistribute it and/or modify
5
+ it under the terms of the GNU Affero General Public License as
6
+ published by the Free Software Foundation, either version 3 of the
7
+ License, or (at your option) any later version.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU Affero General Public License for more details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
16
+ */
17
+
18
+ // Core libraries
19
+ import { NoRetryError } from '@izara_project/izara-core-library-core';
20
+ import Logger from '@izara_project/izara-core-library-logger';
21
+
22
+ // Data and service interaction libraries
23
+ import dynamodbSharedLib from '@izara_project/izara-core-library-dynamodb';
24
+ import sqsSharedLib from '@izara_project/izara-core-library-sqs';
25
+
26
+ // External service interactions
27
+ import { sqs } from '@izara_project/izara-core-library-external-request';
28
+
29
+ /**
30
+ * Create a field name for storing the unique-request id, with optional prefix.
31
+ * @param {string} prefix
32
+ * @param {string} [uniqueRequestIdFieldName='UniqueRequestId']
33
+ * @returns {string}
34
+ */
35
+ export function createFieldNameUniqueRequestId(
36
+ prefix,
37
+ uniqueRequestIdFieldName = 'UniqueRequestId'
38
+ ) {
39
+ return prefix + uniqueRequestIdFieldName;
40
+ }
41
+
42
+ /**
43
+ * Create a concatenated awaitingStepId from composite parts.
44
+ * @param {string} partitionKey
45
+ * @param {string|number} sortId
46
+ * @param {string} [prefix=""]
47
+ * @returns {string}
48
+ */
49
+ export function createConcatenatedAwaitingStepId(
50
+ partitionKey,
51
+ sortId,
52
+ prefix = ''
53
+ ) {
54
+ return prefix + partitionKey + '_' + sortId;
55
+ }
56
+
57
+ /**
58
+ * Create a concatenated pendingStepId from parent/child ids.
59
+ * @param {string} parentId
60
+ * @param {string} childId
61
+ * @returns {string}
62
+ */
63
+ export function createConcatenatedPendingStepId(parentId, childId) {
64
+ return parentId + '_' + childId;
65
+ }
66
+
67
+ /**
68
+ * Create a simple awaitingStepId (prefix + partitionKey).
69
+ * @param {string} partitionKey
70
+ * @param {string} [prefix=""]
71
+ * @returns {string}
72
+ */
73
+ export function createAwaitingStepId(partitionKey, prefix = '') {
74
+ return prefix + partitionKey;
75
+ }
76
+
77
+ /**
78
+ * Create a simple pendingStepId (prefix + identifierId).
79
+ * @param {string} identifierId
80
+ * @param {string} [prefix=""]
81
+ * @returns {string}
82
+ */
83
+ export function createPendingStepId(identifierId, prefix = '') {
84
+ return prefix + identifierId;
85
+ }
86
+
87
+ /**
88
+ * Remove the known prefix from a pendingStepId and validate it is not equal to the prefix.
89
+ * If prefix is empty, the original ID is returned.
90
+ * @param {string} pendingStepId
91
+ * @param {string} [prefix=""]
92
+ * @returns {string}
93
+ * @throws {NoRetryError} If the stripped value equals the prefix (invalid)
94
+ */
95
+ export function explodePendingStepId(pendingStepId, prefix = '') {
96
+ if (prefix === '') {
97
+ return pendingStepId;
98
+ } else {
99
+ let identifierId = pendingStepId.slice(prefix.length);
100
+ // ** validate identifierId == prefix
101
+ if (identifierId == prefix) {
102
+ throw new NoRetryError('IdentifierId should not be like prefix.');
103
+ }
104
+
105
+ Logger.debug('return explode:', identifierId);
106
+ return identifierId;
107
+ }
108
+ }
109
+
110
+ // copy from izara-core-library-trigger-cache
111
+ /**
112
+ * Build the field name that stores "cache complete" marker.
113
+ * @param {string} [prefix='cache']
114
+ * @returns {string}
115
+ */
116
+ export function createFieldNameCacheComplete(prefix = 'cache') {
117
+ return prefix + 'CacheComplete';
118
+ }
119
+
120
+ /**
121
+ * Build the field name that stores a cache "status".
122
+ * @param {string} [prefix='cache']
123
+ * @returns {string}
124
+ */
125
+ export function createFieldNameStatus(prefix = 'cache') {
126
+ return prefix + 'Status';
127
+ }
128
+
129
+ /**
130
+ * Checks if record has uniqueRequestId set, if not set to this requests uniqueRequestId and continue to process
131
+ * if already set check if matches this request uniqueRequestId, if yes continue to process, if not then stop processing
132
+ * status: "process"|"stop"|"recordNotFound"
133
+
134
+ * if unable to set uniqueRequestId (because other thread updated it) throw NoRetryError
135
+ * conditional update existingRecord set uniqueRequestId = _izContext.uniqueRequestId
136
+ * conditional check uniqueRequestId still not exist in case another thread added
137
+ * if conditional not pass "continue" and try again
138
+
139
+ * @returns [string] [returnStatus, existingRecord]
140
+ */
141
+
142
+ /**
143
+ * Coordinate unique-request processing over a record:
144
+ * - If no record: returns ["recordNotFound", {}]
145
+ * - If record exists but no uniqueRequestId: tries to set it via conditional update
146
+ * - If record has a different uniqueRequestId: returns ["stop", existingRecord]
147
+ * - If record has the same uniqueRequestId: returns ["process", existingRecord]
148
+ *
149
+ * Will loop up to 2 attempts to avoid races; throws on 3rd iteration.
150
+ *
151
+ * @async
152
+ * @param {IzContext} _izContext
153
+ * @param {string} tableName - Logical table name known by dynamodbSharedLib.tableName
154
+ * @param {DynamoKey} primaryKey - Primary key for the target record
155
+ * @param {string} [prefix=''] - Optional field-name prefix
156
+ * @param {string|null} [overwriteUniqueRequestId=null] - If provided, use this as the "current" unique request id
157
+ * @param {string} [uniqueRequestIdFieldName="UniqueRequestId"] - Base field name before prefix
158
+ * @returns {Promise<[UniqueRequestStatus, Attrs]>}
159
+ * @throws {NoRetryError} If unable to set uniqueRequestId after 2 tries
160
+ */
161
+ export async function checkUniqueRequestProcessing(
162
+ _izContext,
163
+ tableName,
164
+ primaryKey,
165
+ prefix = '',
166
+ overwriteUniqueRequestId = null,
167
+ uniqueRequestIdFieldName = 'UniqueRequestId' // if set will check/add this fieldname with _izContext.uniqueRequestId value
168
+ ) {
169
+ try {
170
+ _izContext.logger.debug(
171
+ 'current uniqueRequestId:',
172
+ _izContext.uniqueRequestId
173
+ );
174
+
175
+ let uniqueRequestId = _izContext.uniqueRequestId;
176
+ if (overwriteUniqueRequestId) {
177
+ uniqueRequestId = overwriteUniqueRequestId;
178
+ }
179
+
180
+ // add prefix
181
+ uniqueRequestIdFieldName = createFieldNameUniqueRequestId(
182
+ prefix,
183
+ uniqueRequestIdFieldName
184
+ );
185
+
186
+ //loop 3 times incase status changes, on 3rd iteration throw error (so checks 2 times)
187
+ for (let i = 1; i <= 3; i++) {
188
+ if (i == 3) {
189
+ throw new NoRetryError(
190
+ `unable to set uniqueRequestId in table ${tableName}, record`,
191
+ primaryKey
192
+ );
193
+ }
194
+ //* get record from dynamo
195
+ let existingRecord = await dynamodbSharedLib.getItem(
196
+ _izContext,
197
+ await dynamodbSharedLib.tableName(_izContext, tableName),
198
+ primaryKey
199
+ );
200
+ _izContext.logger.debug('existingRecord:', existingRecord);
201
+
202
+ let returnStatus = 'process';
203
+
204
+ if (!existingRecord) {
205
+ // return ["recordNotFound", null]; // cannot return null, js cannot found object and throw error and dont know where it is?
206
+ return ['recordNotFound', {}];
207
+ } else if (!existingRecord[uniqueRequestIdFieldName]) {
208
+ // uniqueRequestId not yet exist
209
+
210
+ let updateAttributes = [
211
+ {
212
+ attributeName: uniqueRequestIdFieldName,
213
+ action: 'set',
214
+ value: _izContext.uniqueRequestId
215
+ }
216
+ ];
217
+ //* conditional check uniqueRequestId still not exist in case another thread added
218
+ const queryElementsWhenUpdate = {
219
+ logicalElements: [
220
+ {
221
+ type: 'function',
222
+ function: 'attribute_not_exists',
223
+ attribute: uniqueRequestIdFieldName
224
+ }
225
+ ],
226
+ returnValues: 'ALL_NEW'
227
+ };
228
+ // // ----- main query ---------
229
+ let updateRecord = null;
230
+ try {
231
+ updateRecord = await dynamodbSharedLib.updateItem(
232
+ _izContext,
233
+ await dynamodbSharedLib.tableName(_izContext, tableName),
234
+ primaryKey,
235
+ updateAttributes,
236
+ queryElementsWhenUpdate,
237
+ { complexAttributes: true } // force to complex
238
+ );
239
+
240
+ return [returnStatus, updateRecord];
241
+ } catch (err) {
242
+ // catch error when put and handle errors.
243
+ _izContext.logger.debug('updateItem storedCache expired err', err);
244
+ if (err.name == 'ConditionalCheckFailedException') {
245
+ continue;
246
+ } else {
247
+ // e.g. ProvisionedThroughputExceededException
248
+ // throw new Error('Unhandled Error when putItem', err) // TT, if throw will stop
249
+ throw err; // TT, if throw will stop
250
+ }
251
+ } //end catch err
252
+ } else if (existingRecord[uniqueRequestIdFieldName] !== uniqueRequestId) {
253
+ // when another request/uniqueRequestId need to stop process
254
+ return ['stop', existingRecord];
255
+ } else if (existingRecord[uniqueRequestIdFieldName] == uniqueRequestId) {
256
+ // if existingRecord[uniqueRequestIdFieldName] == uniqueRequestId then return "process
257
+ return [returnStatus, existingRecord];
258
+ }
259
+ } // end loop 2 times
260
+ //should never get here
261
+ throw new NoRetryError(
262
+ 'unexpected logic flow in checkUniqueRequestProcessing'
263
+ );
264
+ } catch (err) {
265
+ _izContext.logger.error('ERROR checkUniqueRequestProcessing:', err);
266
+ throw err;
267
+ }
268
+ }
269
+
270
+ // ----- Helper funtion, common use case in triggerFlow ---------
271
+ /**
272
+ * Convenience wrapper around checkAndGetTimeCacheComplete.
273
+ * Returns [isSameOrUnset, uniqueRequestIdWhenComplete].
274
+ * @async
275
+ * @param {IzContext} _izContext
276
+ * @param {string} fullMainTableName - Fully resolved table name (not logical)
277
+ * @param {DynamoKey} keyValues
278
+ * @param {number|string} timeCacheComplete - Caller’s expected cache mark
279
+ * @param {string} prefix - Field prefix used in cache fields
280
+ * @returns {Promise<[boolean, string|undefined]>}
281
+ */
282
+ export async function checkTimeCacheComplete(
283
+ _izContext,
284
+ fullMainTableName,
285
+ keyValues,
286
+ timeCacheComplete,
287
+ prefix
288
+ ) {
289
+ let [returnValue, newTimeCacheComplete, uniqueRequestId] =
290
+ await checkAndGetTimeCacheComplete(
291
+ _izContext,
292
+ fullMainTableName,
293
+ keyValues,
294
+ timeCacheComplete,
295
+ prefix
296
+ );
297
+ return [returnValue, uniqueRequestId];
298
+ }
299
+
300
+ /**
301
+ * Read cache fields from a record and compare cacheComplete value to caller’s `timeCacheComplete`.
302
+ * If stored differs (and is present), returns [false, storedValue].
303
+ * Otherwise returns [true, storedValue, uniqueRequestId].
304
+ * @async
305
+ * @param {IzContext} _izContext
306
+ * @param {string} fullMainTableName - Fully resolved table name (not logical)
307
+ * @param {DynamoKey} keyValues
308
+ * @param {number|string} timeCacheComplete
309
+ * @param {string} prefix
310
+ * @returns {Promise<[boolean, any, string|undefined]>}
311
+ * @throws {NoRetryError} If record cannot be fetched
312
+ */
313
+ export async function checkAndGetTimeCacheComplete(
314
+ _izContext,
315
+ fullMainTableName,
316
+ keyValues,
317
+ timeCacheComplete,
318
+ prefix
319
+ ) {
320
+ _izContext.logger.debug('event checkTimeCacheComplete', {
321
+ fullMainTableName: fullMainTableName,
322
+ keyValues: keyValues,
323
+ timeCacheComplete: timeCacheComplete,
324
+ prefix: prefix
325
+ });
326
+
327
+ let uniqueRequestIdFieldName = createFieldNameUniqueRequestId('cache');
328
+ let cacheCompleteFieldName = createFieldNameCacheComplete(prefix);
329
+ let statusFieldName = createFieldNameStatus(prefix);
330
+
331
+ let getTimeCacheComplete = await dynamodbSharedLib.getItem(
332
+ _izContext,
333
+ fullMainTableName,
334
+ keyValues
335
+ );
336
+ _izContext.logger.debug(
337
+ `getTimeCacheComplete from ${fullMainTableName}: `,
338
+ getTimeCacheComplete
339
+ );
340
+
341
+ if (!getTimeCacheComplete) {
342
+ throw new NoRetryError(
343
+ `cannot getTimeCacheComplete from ${fullMainTableName}`
344
+ );
345
+ }
346
+
347
+ // check if exist and match with initail create not changing in flow.
348
+ // if (!getTimeCacheComplete[uniqueRequestIdFieldName]
349
+ // // || !getTimeCacheComplete[cacheCompleteFieldName]
350
+ // || (getTimeCacheComplete[cacheCompleteFieldName]
351
+ // && getTimeCacheComplete[cacheCompleteFieldName] !== timeCacheComplete
352
+ // && getTimeCacheComplete[statusFieldName] != "complete")
353
+ // ) {
354
+ // return [false, getTimeCacheComplete[cacheCompleteFieldName]];
355
+ // } else {
356
+ // return [true, getTimeCacheComplete[cacheCompleteFieldName], getTimeCacheComplete[uniqueRequestIdFieldName]];
357
+ // }
358
+
359
+ if (
360
+ getTimeCacheComplete[cacheCompleteFieldName] &&
361
+ getTimeCacheComplete[cacheCompleteFieldName] !== timeCacheComplete
362
+ ) {
363
+ return [false, getTimeCacheComplete[cacheCompleteFieldName]];
364
+ } else {
365
+ return [
366
+ true,
367
+ getTimeCacheComplete[cacheCompleteFieldName],
368
+ getTimeCacheComplete[uniqueRequestIdFieldName]
369
+ ];
370
+ }
371
+ }
372
+
373
+ // ----- shared both stored cache and triggered cache, check uniqueRequestId changed ---------
374
+ /**
375
+ * Ensure the cache object’s "uniqueRequestIdCompleteFieldName" equals the given checkUniqueRequestId.
376
+ * @async
377
+ * @param {IzContext} _izContext
378
+ * @param {string} fullMainTableName
379
+ * @param {DynamoKey} keyValues
380
+ * @param {string} checkUniqueRequestId
381
+ * @param {string} uniqueRequestIdCompleteFieldName
382
+ * @returns {Promise<boolean>}
383
+ */
384
+ export async function checkCacheUniqueRequestId(
385
+ _izContext,
386
+ fullMainTableName,
387
+ keyValues,
388
+ checkUniqueRequestId,
389
+ uniqueRequestIdCompleteFieldName
390
+ ) {
391
+ let cacheObject = await dynamodbSharedLib.getItem(
392
+ _izContext,
393
+ fullMainTableName,
394
+ keyValues
395
+ );
396
+ _izContext.logger.debug('cacheObject', cacheObject);
397
+
398
+ if (
399
+ !cacheObject[uniqueRequestIdCompleteFieldName] ||
400
+ cacheObject[uniqueRequestIdCompleteFieldName] !== checkUniqueRequestId
401
+ ) {
402
+ return false;
403
+ } else {
404
+ return true;
405
+ }
406
+ }
407
+
408
+ //====================================== end Multiple Lambda Invocations (Logic pagination of handling results)
409
+
410
+ /**
411
+ * Validate and normalize a DynamoDB pagination startKey object.
412
+ * Ensures partitionKeyFieldName/sortKeyFieldName are alphanumeric/underscore/hyphen.
413
+ * If startKey is empty object, returns `null` for easier processing downstream.
414
+ * @param {IzContext} _izContext
415
+ * @param {Attrs|null|undefined} startKey
416
+ * @param {string} partitionKeyFieldName
417
+ * @param {string} sortKeyFieldName
418
+ * @returns {Attrs|null}
419
+ * @throws {NoRetryError} On invalid field names or malformed startKey
420
+ */
421
+ export function validateStartKeyParam(
422
+ _izContext,
423
+ startKey,
424
+ partitionKeyFieldName,
425
+ sortKeyFieldName
426
+ ) {
427
+ const stringNotEmptyRegex = /^[A-Za-z0-9_-]+$/;
428
+
429
+ if (
430
+ !partitionKeyFieldName ||
431
+ !sortKeyFieldName ||
432
+ !stringNotEmptyRegex.test(partitionKeyFieldName) ||
433
+ !stringNotEmptyRegex.test(sortKeyFieldName)
434
+ ) {
435
+ throw new NoRetryError(
436
+ 'validateStartKeyParam: Invalid partitionKeyFieldName or sortKeyFieldName'
437
+ );
438
+ }
439
+
440
+ if (startKey && Object.keys(startKey).length != 0) {
441
+ if (!startKey[partitionKeyFieldName] || !startKey[sortKeyFieldName]) {
442
+ throw new NoRetryError(
443
+ 'validateStartKeyParam: Invalid startKey, missing partitionKeyFieldName or sortKeyFieldName'
444
+ );
445
+ }
446
+ } else {
447
+ startKey = null; // if empty set to null so lib functions process correctly
448
+ }
449
+
450
+ return startKey;
451
+ }
452
+
453
+ /**
454
+ * For paginated multi-invocation flows: validates the current invocation count,
455
+ * attaches the (optional) next startKey to the message, increments the count,
456
+ * and re-enqueues the message to the specified SQS queue.
457
+ * @async
458
+ * @param {IzContext} _izContext
459
+ * @param {Attrs} messageProperty - The message body object to re-dispatch
460
+ * @param {Attrs} [passOnStartKey={}] - Optional startKey to pass along
461
+ * @param {number} numberInvocation - Current invocation count
462
+ * @param {string} queueName - Logical queue name resolvable via sqsSharedLib.sqsQueueUrl
463
+ * @returns {Promise<void>}
464
+ * @throws {NoRetryError} If `numberInvocation` exceeds internal safety limit
465
+ */
466
+ export async function validateMultipleInvocations(
467
+ _izContext,
468
+ messageProperty,
469
+ passOnStartKey = {},
470
+ numberInvocation,
471
+ queueName
472
+ ) {
473
+ _izContext.logger.debug('Lib:validateMultipleInvocations', {
474
+ messageProperty: messageProperty,
475
+ passOnStartKey: passOnStartKey,
476
+ numberInvocation: numberInvocation,
477
+ queueName: queueName
478
+ });
479
+ try {
480
+ const maxProcessInvocationCountImport = 20;
481
+ if (numberInvocation >= maxProcessInvocationCountImport) {
482
+ _izContext.logger.error(
483
+ 'numberInvocation exceeds maxProcessInvocationCountImport limit'
484
+ );
485
+ throw new NoRetryError(
486
+ 'numberInvocation exceeds maxProcessInvocationCountImport limit'
487
+ );
488
+ }
489
+
490
+ if (passOnStartKey) {
491
+ messageProperty['startKey'] = passOnStartKey;
492
+ }
493
+ messageProperty['numberInvocation'] = numberInvocation;
494
+
495
+ let messageReInvokeFunction = {
496
+ MessageBody: JSON.stringify(messageProperty),
497
+ QueueUrl: await sqsSharedLib.sqsQueueUrl(_izContext, queueName)
498
+ };
499
+ _izContext.logger.debug(
500
+ `Send mesage to Dsq:${queueName} `,
501
+ messageReInvokeFunction
502
+ );
503
+ await sqs.sendMessage(_izContext, messageReInvokeFunction);
504
+ } catch (err) {
505
+ throw err;
506
+ }
507
+ }