@cap-js-community/event-queue 2.0.1 → 2.0.2
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/package.json +4 -3
- package/src/EventQueueError.js +15 -0
- package/src/EventQueueProcessorBase.js +55 -44
- package/src/config.js +79 -33
- package/src/dbHandler.js +1 -1
- package/src/outbox/EventQueueGenericOutboxHandler.js +2 -1
- package/src/outbox/eventQueueAsOutbox.js +9 -25
- package/src/periodicEvents.js +2 -2
- package/src/processEventQueue.js +1 -0
- package/src/publishEvent.js +6 -4
- package/src/redis/redisSub.js +9 -6
- package/src/runner/openEvents.js +14 -17
- package/src/runner/runnerHelper.js +3 -3
- package/src/shared/cdsHelper.js +1 -1
- package/src/shared/distributedLock.js +3 -2
- package/srv/service/admin-service.cds +6 -13
- package/srv/service/admin-service.js +14 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js-community/event-queue",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "An event queue that enables secure transactional processing of asynchronous and periodic events, featuring instant event processing with Redis Pub/Sub and load distribution across all application instances.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -56,10 +56,11 @@
|
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@actions/core": "^1.11.1",
|
|
58
58
|
"@cap-js/cds-test": "^0.4.0",
|
|
59
|
+
"@cap-js/db-service": "^2.6.0",
|
|
59
60
|
"@cap-js/hana": "^2.3.4",
|
|
60
|
-
"@cap-js/sqlite": "^2.0
|
|
61
|
+
"@cap-js/sqlite": "^2.1.0",
|
|
61
62
|
"@opentelemetry/api": "^1.9.0",
|
|
62
|
-
"@sap/cds": "^9.4.
|
|
63
|
+
"@sap/cds": "^9.4.5",
|
|
63
64
|
"@sap/cds-dk": "^9.4.2",
|
|
64
65
|
"eslint": "^8.57.0",
|
|
65
66
|
"eslint-config-prettier": "^9.1.0",
|
package/src/EventQueueError.js
CHANGED
|
@@ -27,12 +27,16 @@ const ERROR_CODES = {
|
|
|
27
27
|
APP_NAMES_FORMAT: "APP_NAMES_FORMAT",
|
|
28
28
|
APP_INSTANCES_FORMAT: "APP_INSTANCES_FORMAT",
|
|
29
29
|
MULTI_INSTANCE_PROCESSING_NOT_ALLOWED: "MULTI_INSTANCE_PROCESSING_NOT_ALLOWED",
|
|
30
|
+
INVALID_CLUSTER_HANDLER_RESULT: "INVALID_CLUSTER_HANDLER_RESULT",
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
const ERROR_CODES_META = {
|
|
33
34
|
[ERROR_CODES.WRONG_TX_USAGE]: {
|
|
34
35
|
message: "Usage of this.tx|this.context is not allowed if parallel event processing is enabled",
|
|
35
36
|
},
|
|
37
|
+
[ERROR_CODES.INVALID_CLUSTER_HANDLER_RESULT]: {
|
|
38
|
+
message: "A cluster handler returned an invalid result. Cluster handlers must return the clustered payload data.",
|
|
39
|
+
},
|
|
36
40
|
[ERROR_CODES.UNKNOWN_EVENT_TYPE]: {
|
|
37
41
|
message: "The event type and subType configuration is not configured! Maintain the combination in the config file.",
|
|
38
42
|
},
|
|
@@ -371,6 +375,17 @@ class EventQueueError extends VError {
|
|
|
371
375
|
);
|
|
372
376
|
}
|
|
373
377
|
|
|
378
|
+
static invalidClusterHandlerResult(clusterKey, propertyName) {
|
|
379
|
+
const { message } = ERROR_CODES_META[ERROR_CODES.INVALID_CLUSTER_HANDLER_RESULT];
|
|
380
|
+
return new EventQueueError(
|
|
381
|
+
{
|
|
382
|
+
name: ERROR_CODES.INVALID_CLUSTER_HANDLER_RESULT,
|
|
383
|
+
info: { clusterKey, propertyName },
|
|
384
|
+
},
|
|
385
|
+
message
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
374
389
|
static isRedisConnectionFailure(err) {
|
|
375
390
|
return err instanceof VError && err.name === ERROR_CODES.REDIS_CREATE_CLIENT;
|
|
376
391
|
}
|
|
@@ -62,6 +62,7 @@ class EventQueueProcessorBase {
|
|
|
62
62
|
this.#eventType = eventType;
|
|
63
63
|
this.#eventSubType = eventSubType;
|
|
64
64
|
this.#eventConfig = config ?? {};
|
|
65
|
+
this.#eventConfig.selectedDelayedEventIds ??= [];
|
|
65
66
|
this.__parallelEventProcessing = this.#eventConfig.parallelEventProcessing ?? DEFAULT_PARALLEL_EVENT_PROCESSING;
|
|
66
67
|
if (this.__parallelEventProcessing > LIMIT_PARALLEL_EVENT_PROCESSING) {
|
|
67
68
|
this.__parallelEventProcessing = LIMIT_PARALLEL_EVENT_PROCESSING;
|
|
@@ -664,45 +665,49 @@ class EventQueueProcessorBase {
|
|
|
664
665
|
const baseDate = Date.now();
|
|
665
666
|
const refDateStartAfter = new Date(baseDate + this.#config.runInterval * 1.2);
|
|
666
667
|
await executeInNewTransaction(this.__baseContext, "eventQueue-getQueueEntriesAndSetToInProgress", async (tx) => {
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
)
|
|
668
|
+
const cqn = SELECT.from(this.#config.tableNameEventQueue)
|
|
669
|
+
.forUpdate({ wait: this.#config.forUpdateTimeout })
|
|
670
|
+
.limit(this.selectMaxChunkSize)
|
|
671
|
+
.where(
|
|
672
|
+
"type =",
|
|
673
|
+
this.#eventType,
|
|
674
|
+
"AND subType=",
|
|
675
|
+
this.#eventSubType,
|
|
676
|
+
"AND namespace =",
|
|
677
|
+
this.#namespace,
|
|
678
|
+
"AND ( startAfter IS NULL OR startAfter <=",
|
|
679
|
+
refDateStartAfter.toISOString(),
|
|
680
|
+
" ) AND ( status =",
|
|
681
|
+
EventProcessingStatus.Open,
|
|
682
|
+
"AND ( lastAttemptTimestamp <=",
|
|
683
|
+
this.startTime.toISOString(),
|
|
684
|
+
...(this.isPeriodicEvent
|
|
685
|
+
? [
|
|
686
|
+
"OR lastAttemptTimestamp IS NULL ) OR ( status =",
|
|
687
|
+
EventProcessingStatus.InProgress,
|
|
688
|
+
"AND lastAttemptTimestamp <=",
|
|
689
|
+
new Date(baseDate - this.#eventConfig.keepAliveMaxInProgressTime * 1000).toISOString(),
|
|
690
|
+
") ) ",
|
|
691
|
+
]
|
|
692
|
+
: [
|
|
693
|
+
"OR lastAttemptTimestamp IS NULL ) OR ( status =",
|
|
694
|
+
EventProcessingStatus.Error,
|
|
695
|
+
"AND lastAttemptTimestamp <=",
|
|
696
|
+
this.startTime.toISOString(),
|
|
697
|
+
") OR ( status =",
|
|
698
|
+
EventProcessingStatus.InProgress,
|
|
699
|
+
"AND lastAttemptTimestamp <=",
|
|
700
|
+
new Date(baseDate - this.#eventConfig.keepAliveMaxInProgressTime * 1000).toISOString(),
|
|
701
|
+
") )",
|
|
702
|
+
])
|
|
703
|
+
)
|
|
704
|
+
.orderBy("createdAt", "ID");
|
|
705
|
+
|
|
706
|
+
if (this.#eventConfig.selectedDelayedEventIds) {
|
|
707
|
+
cqn.where("ID NOT IN", this.#eventConfig.selectedDelayedEventIds);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const entries = await tx.run(cqn);
|
|
706
711
|
|
|
707
712
|
if (!entries.length) {
|
|
708
713
|
this.logger.debug("no entries available for processing", {
|
|
@@ -756,7 +761,9 @@ class EventQueueProcessorBase {
|
|
|
756
761
|
}
|
|
757
762
|
|
|
758
763
|
if (!eventsForProcessing.length) {
|
|
759
|
-
|
|
764
|
+
if (!entries.length) {
|
|
765
|
+
this.__emptyChunkSelected = true;
|
|
766
|
+
}
|
|
760
767
|
return;
|
|
761
768
|
}
|
|
762
769
|
|
|
@@ -806,8 +813,11 @@ class EventQueueProcessorBase {
|
|
|
806
813
|
return entry.lastAttemptsTs;
|
|
807
814
|
}
|
|
808
815
|
|
|
809
|
-
#handleDelayedEvents(delayedEvents) {
|
|
816
|
+
#handleDelayedEvents(delayedEvents, { skipExcludeDelayedEventIds = false } = {}) {
|
|
810
817
|
for (const delayedEvent of delayedEvents) {
|
|
818
|
+
if (!skipExcludeDelayedEventIds) {
|
|
819
|
+
this.#eventConfig.selectedDelayedEventIds.push(delayedEvent.ID);
|
|
820
|
+
}
|
|
811
821
|
this.#eventSchedulerInstance.scheduleEvent(
|
|
812
822
|
this.__context.tenant,
|
|
813
823
|
this.#eventType,
|
|
@@ -1107,6 +1117,7 @@ class EventQueueProcessorBase {
|
|
|
1107
1117
|
}
|
|
1108
1118
|
|
|
1109
1119
|
const newEvent = {
|
|
1120
|
+
ID: cds.utils.uuid(),
|
|
1110
1121
|
type: this.#eventType,
|
|
1111
1122
|
subType: this.#eventSubType,
|
|
1112
1123
|
namespace: this.#eventConfig.namespace,
|
|
@@ -1131,16 +1142,16 @@ class EventQueueProcessorBase {
|
|
|
1131
1142
|
});
|
|
1132
1143
|
}
|
|
1133
1144
|
|
|
1134
|
-
this.tx.
|
|
1145
|
+
this.tx._skipEventQueueBroadcast = true;
|
|
1135
1146
|
await this.tx.run(
|
|
1136
1147
|
INSERT.into(this.#config.tableNameEventQueue).entries({
|
|
1137
1148
|
...newEvent,
|
|
1138
1149
|
startAfter: newEvent.startAfter.toISOString(),
|
|
1139
1150
|
})
|
|
1140
1151
|
);
|
|
1141
|
-
this.tx.
|
|
1152
|
+
this.tx._skipEventQueueBroadcast = false;
|
|
1142
1153
|
if (intervalInMs < this.#config.runInterval * 1.5) {
|
|
1143
|
-
this.#handleDelayedEvents([newEvent]);
|
|
1154
|
+
this.#handleDelayedEvents([newEvent], { skipExcludeDelayedEventIds: true });
|
|
1144
1155
|
const { relative: relativeAfterSchedule } = this.#eventSchedulerInstance.calculateOffset(
|
|
1145
1156
|
this.#eventType,
|
|
1146
1157
|
this.#eventSubType,
|
package/src/config.js
CHANGED
|
@@ -252,9 +252,8 @@ class Config {
|
|
|
252
252
|
this.#unsubscribeHandlers.push(cb);
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
addCAPOutboxEventBase(serviceName, config) {
|
|
255
|
+
addCAPOutboxEventBase(serviceName, config, currentEvents) {
|
|
256
256
|
const namespace = config.namespace ?? this.#namespace;
|
|
257
|
-
delete this.#eventMap[this.generateKey(namespace, CAP_EVENT_TYPE, serviceName)];
|
|
258
257
|
|
|
259
258
|
// NOTE: CAP outbox defaults are injected by cds.requires.outbox // cds.requires.queue
|
|
260
259
|
const eventConfig = this.#sanitizeParamsAdHocEvent({
|
|
@@ -262,44 +261,45 @@ class Config {
|
|
|
262
261
|
subType: serviceName,
|
|
263
262
|
impl: "./outbox/EventQueueGenericOutboxHandler",
|
|
264
263
|
kind: config.kind ?? "persistent-queue",
|
|
265
|
-
|
|
266
|
-
parallelEventProcessing: config.parallelEventProcessing ?? (config.parallel && CAP_PARALLEL_DEFAULT),
|
|
267
|
-
retryAttempts: config.retryAttempts ?? config.maxAttempts ?? CAP_MAX_ATTEMPTS_DEFAULT,
|
|
264
|
+
...this.#mixCAPPropertyNamesWithEventQueueNames(config),
|
|
268
265
|
namespace,
|
|
269
266
|
...config,
|
|
270
267
|
});
|
|
268
|
+
eventConfig.retryAttempts ??= CAP_MAX_ATTEMPTS_DEFAULT;
|
|
271
269
|
eventConfig.internalEvent = true;
|
|
272
270
|
|
|
273
271
|
this.#basicEventTransformation(eventConfig);
|
|
274
|
-
this.#validateAdHocEvents(
|
|
272
|
+
this.#validateAdHocEvents(currentEvents, eventConfig, false);
|
|
275
273
|
this.#basicEventTransformationAfterValidate(eventConfig);
|
|
276
|
-
|
|
274
|
+
return [this.generateKey(namespace, CAP_EVENT_TYPE, serviceName), eventConfig];
|
|
277
275
|
}
|
|
278
276
|
|
|
279
|
-
|
|
280
|
-
|
|
277
|
+
#mixCAPPropertyNamesWithEventQueueNames(config) {
|
|
278
|
+
return this.#cleanUndefined({
|
|
279
|
+
selectMaxChunkSize: config.selectMaxChunkSize ?? config.chunkSize,
|
|
280
|
+
parallelEventProcessing: config.parallelEventProcessing ?? (config.parallel && CAP_PARALLEL_DEFAULT),
|
|
281
|
+
retryAttempts: config.retryAttempts ?? config.maxAttempts,
|
|
282
|
+
...config,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
281
285
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
delete this.#eventMap[this.generateKey(specific.namespace, CAP_EVENT_TYPE, subType)];
|
|
285
|
-
}
|
|
286
|
+
addCAPOutboxEventSpecificAction(baseServiceConfig, serviceName, actionName, currentEvents) {
|
|
287
|
+
const subType = [serviceName, actionName].join(".");
|
|
286
288
|
|
|
287
|
-
const base = this.#findBaseCAPServiceWithoutNamespace(serviceName);
|
|
288
289
|
const eventConfig = this.#sanitizeParamsAdHocEvent({
|
|
289
|
-
...
|
|
290
|
+
...baseServiceConfig,
|
|
290
291
|
...this.getCdsOutboxEventSpecificConfig(serviceName, actionName),
|
|
291
292
|
subType,
|
|
292
293
|
});
|
|
293
294
|
eventConfig.internalEvent = true;
|
|
294
295
|
|
|
295
296
|
this.#basicEventTransformation(eventConfig);
|
|
296
|
-
this.#validateAdHocEvents(
|
|
297
|
+
this.#validateAdHocEvents(currentEvents, eventConfig, false);
|
|
297
298
|
this.#basicEventTransformationAfterValidate(eventConfig);
|
|
298
|
-
|
|
299
|
-
return eventConfig;
|
|
299
|
+
return [this.generateKey(eventConfig.namespace, CAP_EVENT_TYPE, subType), eventConfig];
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
|
|
302
|
+
findBaseCAPServiceWithoutNamespace(serviceName) {
|
|
303
303
|
return Object.values(this.#eventMap).find(
|
|
304
304
|
(event) => event.type === CAP_EVENT_TYPE && event.subType === serviceName
|
|
305
305
|
);
|
|
@@ -318,22 +318,39 @@ class Config {
|
|
|
318
318
|
fileContent.periodicEvents ??= [];
|
|
319
319
|
const events = this.#configEvents ?? {};
|
|
320
320
|
const periodicEvents = this.#configPeriodicEvents ?? {};
|
|
321
|
-
const
|
|
322
|
-
fileContent.events = fileContent.events.concat(this.#mapEnvEvents(events));
|
|
321
|
+
const { adHoc: adHocCAP, periodicEvents: periodicEventsCAP } = this.#cdsQueueEventsFromEnv();
|
|
322
|
+
fileContent.events = fileContent.events.concat(this.#mapEnvEvents(events)).concat(Object.values(adHocCAP));
|
|
323
323
|
fileContent.periodicEvents = fileContent.periodicEvents
|
|
324
324
|
.concat(this.#mapEnvEvents(periodicEvents))
|
|
325
|
-
.concat(
|
|
325
|
+
.concat(Object.values(periodicEventsCAP));
|
|
326
326
|
this.fileContent = fileContent;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
#
|
|
330
|
-
return Object.
|
|
329
|
+
#getBasicCapOutboxEventConfig(serviceName) {
|
|
330
|
+
return Object.assign(
|
|
331
|
+
{},
|
|
332
|
+
(typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
|
|
333
|
+
(typeof cds.requires.queue === "object" && cds.requires.queue) || {},
|
|
334
|
+
(typeof cds.env.requires[serviceName]?.outbox === "object" && cds.env.requires[serviceName].outbox) || {},
|
|
335
|
+
(typeof cds.env.requires[serviceName]?.queued === "object" && cds.env.requires[serviceName].queued) || {}
|
|
336
|
+
);
|
|
331
337
|
}
|
|
332
338
|
|
|
333
|
-
#
|
|
334
|
-
return Object.entries(cds.env.requires).reduce(
|
|
335
|
-
|
|
336
|
-
|
|
339
|
+
#cdsQueueEventsFromEnv() {
|
|
340
|
+
return Object.entries(cds.env.requires).reduce(
|
|
341
|
+
(result, [name, value]) => {
|
|
342
|
+
const config = value.outbox ?? value.queued;
|
|
343
|
+
|
|
344
|
+
if (!config) {
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// make sure service is known in general
|
|
349
|
+
// only if not known
|
|
350
|
+
const basicServiceConfig = this.#getBasicCapOutboxEventConfig(name);
|
|
351
|
+
const [key, srvConfig] = this.addCAPOutboxEventBase(name, basicServiceConfig, result.adHoc);
|
|
352
|
+
result.adHoc[key] = srvConfig;
|
|
353
|
+
|
|
337
354
|
for (const fnName in config.events) {
|
|
338
355
|
const base = { ...config };
|
|
339
356
|
const fnConfig = config.events[fnName];
|
|
@@ -348,7 +365,7 @@ class Config {
|
|
|
348
365
|
}
|
|
349
366
|
|
|
350
367
|
const subType = `${name}.${fnName}`;
|
|
351
|
-
result[subType] = Object.assign(
|
|
368
|
+
result.periodicEvents[subType] = Object.assign(
|
|
352
369
|
{
|
|
353
370
|
type: CAP_EVENT_TYPE,
|
|
354
371
|
subType,
|
|
@@ -358,18 +375,38 @@ class Config {
|
|
|
358
375
|
base,
|
|
359
376
|
fnConfig
|
|
360
377
|
);
|
|
378
|
+
} else {
|
|
379
|
+
const [key, specificEventConfig] = this.addCAPOutboxEventSpecificAction(
|
|
380
|
+
srvConfig,
|
|
381
|
+
name,
|
|
382
|
+
fnName,
|
|
383
|
+
result.adHoc
|
|
384
|
+
);
|
|
385
|
+
result.adHoc[key] = specificEventConfig;
|
|
361
386
|
}
|
|
362
387
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
388
|
+
return result;
|
|
389
|
+
},
|
|
390
|
+
{ adHoc: {}, periodicEvents: {} }
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
addCAPServiceWithoutEnvConfig(serviceName, srv, customOpts = {}) {
|
|
395
|
+
const queueOptions = srv.options.queued ?? srv.options.outbox ?? {};
|
|
396
|
+
const basicServiceConfig = this.#getBasicCapOutboxEventConfig(serviceName);
|
|
397
|
+
const [key, srvConfig] = this.addCAPOutboxEventBase(
|
|
398
|
+
serviceName,
|
|
399
|
+
{ ...basicServiceConfig, ...queueOptions, ...customOpts },
|
|
400
|
+
this.#eventMap
|
|
401
|
+
);
|
|
402
|
+
this.#eventMap[key] = srvConfig;
|
|
366
403
|
}
|
|
367
404
|
|
|
368
405
|
getCdsOutboxEventSpecificConfig(serviceName, action) {
|
|
369
406
|
const srv = cds.env.requires[serviceName];
|
|
370
407
|
const config = srv?.outbox ?? srv?.queued;
|
|
371
408
|
if (config?.events?.[action]) {
|
|
372
|
-
return config.events[action];
|
|
409
|
+
return this.#mixCAPPropertyNamesWithEventQueueNames(config.events[action]);
|
|
373
410
|
} else {
|
|
374
411
|
return null;
|
|
375
412
|
}
|
|
@@ -877,6 +914,15 @@ class Config {
|
|
|
877
914
|
this.#processingNamespaces = value;
|
|
878
915
|
}
|
|
879
916
|
|
|
917
|
+
#cleanUndefined(input) {
|
|
918
|
+
return Object.entries(input).reduce((acc, [key, value]) => {
|
|
919
|
+
if (value !== undefined) {
|
|
920
|
+
acc[key] = value;
|
|
921
|
+
}
|
|
922
|
+
return acc;
|
|
923
|
+
}, {});
|
|
924
|
+
}
|
|
925
|
+
|
|
880
926
|
/**
|
|
881
927
|
@return { Config }
|
|
882
928
|
**/
|
package/src/dbHandler.js
CHANGED
|
@@ -19,7 +19,7 @@ const registerEventQueueDbHandler = (dbService) => {
|
|
|
19
19
|
registeredHandlers.eventQueueDbHandler = true;
|
|
20
20
|
const def = dbService.model.definitions[config.tableNameEventQueue];
|
|
21
21
|
dbService.after("CREATE", def, (_, req) => {
|
|
22
|
-
if (req.tx.
|
|
22
|
+
if (req.tx._skipEventQueueBroadcast) {
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
25
|
req.tx._ = req.tx._ ?? {};
|
|
@@ -6,6 +6,7 @@ const EventQueueBaseClass = require("../EventQueueProcessorBase");
|
|
|
6
6
|
const { EventProcessingStatus } = require("../constants");
|
|
7
7
|
const common = require("../shared/common");
|
|
8
8
|
const config = require("../config");
|
|
9
|
+
const EventQueueError = require("../EventQueueError");
|
|
9
10
|
|
|
10
11
|
const COMPONENT_NAME = "/eventQueue/outbox/generic";
|
|
11
12
|
|
|
@@ -164,7 +165,7 @@ class EventQueueGenericOutboxHandler extends EventQueueBaseClass {
|
|
|
164
165
|
clusterData.queueEntries.map((entry) => entry.payload.data)
|
|
165
166
|
);
|
|
166
167
|
if (!clusterResult) {
|
|
167
|
-
throw
|
|
168
|
+
throw EventQueueError.invalidClusterHandlerResult(clustersKey, propertyName);
|
|
168
169
|
}
|
|
169
170
|
clusterData.payload.data = clusterResult;
|
|
170
171
|
}
|
|
@@ -20,16 +20,6 @@ function outboxed(srv, customOpts) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const logger = cds.log(COMPONENT_NAME);
|
|
23
|
-
let outboxOpts = Object.assign(
|
|
24
|
-
{},
|
|
25
|
-
(typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
|
|
26
|
-
(typeof cds.requires.queue === "object" && cds.requires.queue) || {},
|
|
27
|
-
(typeof srv.options?.outbox === "object" && srv.options.outbox) || {},
|
|
28
|
-
(typeof srv.options?.queued === "object" && srv.options.queued) || {},
|
|
29
|
-
customOpts || {}
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
config.addCAPOutboxEventBase(srv.name, outboxOpts);
|
|
33
23
|
|
|
34
24
|
const originalSrv = srv[UNBOXED] || srv;
|
|
35
25
|
const outboxedSrv = Object.create(originalSrv);
|
|
@@ -42,25 +32,19 @@ function outboxed(srv, customOpts) {
|
|
|
42
32
|
}
|
|
43
33
|
outboxedSrv.handle = async function (req) {
|
|
44
34
|
const context = req.context || cds.context;
|
|
45
|
-
outboxOpts = Object.assign(
|
|
46
|
-
{},
|
|
47
|
-
(typeof cds.requires.outbox === "object" && cds.requires.outbox) || {},
|
|
48
|
-
(typeof cds.requires.queue === "object" && cds.requires.queue) || {},
|
|
49
|
-
(typeof srv.options?.outbox === "object" && srv.options.outbox) || {},
|
|
50
|
-
(typeof srv.options?.queued === "object" && srv.options.queued) || {},
|
|
51
|
-
customOpts || {}
|
|
52
|
-
);
|
|
53
|
-
config.addCAPOutboxEventBase(srv.name, outboxOpts);
|
|
54
35
|
const hasSpecificSettings = !!config.getCdsOutboxEventSpecificConfig(srv.name, req.event);
|
|
55
|
-
if (hasSpecificSettings) {
|
|
56
|
-
outboxOpts = config.addCAPOutboxEventSpecificAction(srv.name, req.event);
|
|
57
|
-
}
|
|
58
36
|
const subType = hasSpecificSettings ? [srv.name, req.event].join(".") : srv.name;
|
|
59
|
-
|
|
60
|
-
|
|
37
|
+
let srvConfig = config.findBaseCAPServiceWithoutNamespace(subType);
|
|
38
|
+
// NOTE: service is outboxed without config in cds.env.requires[srv]
|
|
39
|
+
if (!srvConfig) {
|
|
40
|
+
config.addCAPServiceWithoutEnvConfig(subType, srv, customOpts);
|
|
41
|
+
srvConfig = config.findBaseCAPServiceWithoutNamespace(subType);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const outboxOpts = config.getEventConfig(CDS_EVENT_TYPE, subType, srvConfig.namespace);
|
|
61
45
|
const eventHeaders = getPropagatedHeaders(outboxOpts, req);
|
|
62
46
|
if (["persistent-outbox", "persistent-queue"].includes(outboxOpts.kind)) {
|
|
63
|
-
await _mapToEventAndPublish(context, subType, req, eventHeaders, namespace);
|
|
47
|
+
await _mapToEventAndPublish(context, subType, req, eventHeaders, srvConfig.namespace);
|
|
64
48
|
return;
|
|
65
49
|
}
|
|
66
50
|
context.on("succeeded", async () => {
|
package/src/periodicEvents.js
CHANGED
|
@@ -171,9 +171,9 @@ const _insertPeriodEvents = async (tx, events, now) => {
|
|
|
171
171
|
counter++;
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
tx.
|
|
174
|
+
tx._skipEventQueueBroadcast = true;
|
|
175
175
|
await tx.run(INSERT.into(eventConfig.tableNameEventQueue).entries(eventsToBeInserted));
|
|
176
|
-
tx.
|
|
176
|
+
tx._skipEventQueueBroadcast = false;
|
|
177
177
|
};
|
|
178
178
|
|
|
179
179
|
const _generateKey = ({ type, subType, namespace }) => [namespace, type, subType].join("##");
|
package/src/processEventQueue.js
CHANGED
|
@@ -137,6 +137,7 @@ const processPeriodicEvent = async (context, eventTypeInstance) => {
|
|
|
137
137
|
eventTypeInstance.processEventContext = tx.context;
|
|
138
138
|
const queueEntries = await eventTypeInstance.getQueueEntriesAndSetToInProgress();
|
|
139
139
|
if (!queueEntries.length) {
|
|
140
|
+
processNext = false;
|
|
140
141
|
return;
|
|
141
142
|
}
|
|
142
143
|
if (queueEntries.length > 1) {
|
package/src/publishEvent.js
CHANGED
|
@@ -77,17 +77,17 @@ const publishEvent = async (
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
if (config.insertEventsBeforeCommit && !skipInsertEventsBeforeCommit) {
|
|
80
|
-
_registerHandlerAndAddEvents(tx, events);
|
|
80
|
+
_registerHandlerAndAddEvents(tx, events, skipBroadcast);
|
|
81
81
|
} else {
|
|
82
82
|
let result;
|
|
83
|
-
tx.
|
|
83
|
+
tx._skipEventQueueBroadcast = skipBroadcast;
|
|
84
84
|
result = await tx.run(INSERT.into(config.tableNameEventQueue).entries(events));
|
|
85
|
-
tx.
|
|
85
|
+
tx._skipEventQueueBroadcast = false;
|
|
86
86
|
return result;
|
|
87
87
|
}
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
-
const _registerHandlerAndAddEvents = (tx, events) => {
|
|
90
|
+
const _registerHandlerAndAddEvents = (tx, events, skipBroadcast) => {
|
|
91
91
|
tx._eventQueue ??= { events: [], handlerRegistered: false };
|
|
92
92
|
tx._eventQueue.events = tx._eventQueue.events.concat(events);
|
|
93
93
|
|
|
@@ -99,7 +99,9 @@ const _registerHandlerAndAddEvents = (tx, events) => {
|
|
|
99
99
|
if (!tx._eventQueue.events?.length) {
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
|
+
tx._skipEventQueueBroadcast = skipBroadcast;
|
|
102
103
|
await tx.run(INSERT.into(config.tableNameEventQueue).entries(tx._eventQueue.events));
|
|
104
|
+
tx._skipEventQueueBroadcast = false;
|
|
103
105
|
tx._eventQueue = null;
|
|
104
106
|
});
|
|
105
107
|
};
|
package/src/redis/redisSub.js
CHANGED
|
@@ -48,17 +48,20 @@ const _messageHandlerProcessEvents = async (messageData) => {
|
|
|
48
48
|
if (config.isCapOutboxEvent(type)) {
|
|
49
49
|
try {
|
|
50
50
|
const service = await cds.connect.to(srvName);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
51
|
+
if (!service || actionName) {
|
|
52
|
+
logger.warn("could not find CAP Service configuration to process event!", {
|
|
53
|
+
type,
|
|
54
|
+
subType,
|
|
55
|
+
namespace,
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
57
58
|
}
|
|
59
|
+
config.addCAPServiceWithoutEnvConfig(subType, service);
|
|
58
60
|
} catch (err) {
|
|
59
61
|
logger.warn("could not connect to outboxed service", err, {
|
|
60
62
|
type,
|
|
61
63
|
subType,
|
|
64
|
+
namespace,
|
|
62
65
|
});
|
|
63
66
|
return;
|
|
64
67
|
}
|
package/src/runner/openEvents.js
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const cds = require("@sap/cds");
|
|
4
|
-
|
|
5
|
-
const eventConfig = require("../config");
|
|
6
3
|
const { EventProcessingStatus } = require("../constants");
|
|
7
4
|
const config = require("../config");
|
|
8
5
|
|
|
@@ -10,9 +7,9 @@ const MS_IN_DAYS = 24 * 60 * 60 * 1000;
|
|
|
10
7
|
|
|
11
8
|
const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
|
|
12
9
|
const startTime = new Date();
|
|
13
|
-
const refDateStartAfter = new Date(startTime.getTime() +
|
|
10
|
+
const refDateStartAfter = new Date(startTime.getTime() + config.runInterval * 1.2);
|
|
14
11
|
const entries = await tx.run(
|
|
15
|
-
SELECT.from(
|
|
12
|
+
SELECT.from(config.tableNameEventQueue)
|
|
16
13
|
.where(
|
|
17
14
|
"namespace IN",
|
|
18
15
|
config.processingNamespaces,
|
|
@@ -25,7 +22,7 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
|
|
|
25
22
|
") OR ( status =",
|
|
26
23
|
EventProcessingStatus.InProgress,
|
|
27
24
|
"AND lastAttemptTimestamp <=",
|
|
28
|
-
new Date(startTime.getTime() -
|
|
25
|
+
new Date(startTime.getTime() - config.globalTxTimeout).toISOString(),
|
|
29
26
|
") ) AND (createdAt >=",
|
|
30
27
|
new Date(startTime.getTime() - 30 * MS_IN_DAYS).toISOString(),
|
|
31
28
|
" OR startAfter >=",
|
|
@@ -38,19 +35,19 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
|
|
|
38
35
|
|
|
39
36
|
const result = [];
|
|
40
37
|
for (const { type, subType, namespace } of entries) {
|
|
41
|
-
if (
|
|
38
|
+
if (config.isCapOutboxEvent(type)) {
|
|
42
39
|
const { srvName, actionName } = config.normalizeSubType(type, subType);
|
|
43
40
|
try {
|
|
44
|
-
const service = await cds.connect.to(srvName);
|
|
45
41
|
if (filterAppSpecificEvents) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
const eventConfig = config.getEventConfig(type, subType, namespace);
|
|
43
|
+
if (!eventConfig) {
|
|
44
|
+
const service = await cds.connect.to(srvName);
|
|
45
|
+
if (!service || actionName) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
config.addCAPServiceWithoutEnvConfig(subType, service);
|
|
52
49
|
}
|
|
53
|
-
if (
|
|
50
|
+
if (config.shouldBeProcessedInThisApplication(type, subType, namespace)) {
|
|
54
51
|
result.push({ namespace, type, subType });
|
|
55
52
|
}
|
|
56
53
|
}
|
|
@@ -64,8 +61,8 @@ const getOpenQueueEntries = async (tx, filterAppSpecificEvents = true) => {
|
|
|
64
61
|
} else {
|
|
65
62
|
if (filterAppSpecificEvents) {
|
|
66
63
|
if (
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
config.getEventConfig(type, subType, namespace) &&
|
|
65
|
+
config.shouldBeProcessedInThisApplication(type, subType, namespace)
|
|
69
66
|
) {
|
|
70
67
|
result.push({ namespace, type, subType });
|
|
71
68
|
}
|
|
@@ -4,7 +4,7 @@ const { AsyncResource } = require("async_hooks");
|
|
|
4
4
|
|
|
5
5
|
const cds = require("@sap/cds");
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const processor = require("../processEventQueue");
|
|
8
8
|
const eventQueueConfig = require("../config");
|
|
9
9
|
const WorkerQueue = require("../shared/WorkerQueue");
|
|
10
10
|
const distributedLock = require("../shared/distributedLock");
|
|
@@ -21,7 +21,7 @@ const runEventCombinationForTenant = async (
|
|
|
21
21
|
) => {
|
|
22
22
|
try {
|
|
23
23
|
if (skipWorkerPool) {
|
|
24
|
-
return await processEventQueue(context, type, subType, namespace);
|
|
24
|
+
return await processor.processEventQueue(context, type, subType, namespace);
|
|
25
25
|
} else {
|
|
26
26
|
const eventConfig = eventQueueConfig.getEventConfig(type, subType, namespace);
|
|
27
27
|
const label = `${type}_${subType}`;
|
|
@@ -39,7 +39,7 @@ const runEventCombinationForTenant = async (
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
await processEventQueue(context, type, subType, namespace);
|
|
42
|
+
await processor.processEventQueue(context, type, subType, namespace);
|
|
43
43
|
};
|
|
44
44
|
if (shouldTrace) {
|
|
45
45
|
return await trace(context, label, _exec, { newRootSpan: true });
|
package/src/shared/cdsHelper.js
CHANGED
|
@@ -26,7 +26,7 @@ const CONCURRENCY_AUTH_INFO = 3;
|
|
|
26
26
|
async function executeInNewTransaction(context = {}, transactionTag, fn, args, { info = {} } = {}) {
|
|
27
27
|
const parameters = Array.isArray(args) ? args : [args];
|
|
28
28
|
const logger = cds.log(COMPONENT_NAME);
|
|
29
|
-
let transactionRollbackPromise = Promise.resolve(
|
|
29
|
+
let transactionRollbackPromise = Promise.resolve(true);
|
|
30
30
|
try {
|
|
31
31
|
const authInfo = await common.getAuthContext(context.tenant);
|
|
32
32
|
const user = new cds.User.Privileged({ id: config.userId, authInfo, tokenInfo: authInfo?.token });
|
|
@@ -216,14 +216,15 @@ const getAllLocksRedis = async () => {
|
|
|
216
216
|
// NOTE: use SCAN because KEYS is not supported for cluster clients
|
|
217
217
|
for (const client of clients) {
|
|
218
218
|
for await (const key of client.scanIterator({ MATCH: "EVENT*", COUNT: 1000 })) {
|
|
219
|
-
const [, tenant, guidOrType, subType] = key.split("##");
|
|
219
|
+
const [, namespace, tenant, guidOrType, subType] = key.split("##");
|
|
220
220
|
if (!subType) {
|
|
221
221
|
continue;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
const pipeline = client.multi();
|
|
225
225
|
output.push({
|
|
226
|
-
|
|
226
|
+
namespace,
|
|
227
|
+
tenant,
|
|
227
228
|
type: guidOrType,
|
|
228
229
|
subType: subType,
|
|
229
230
|
});
|
|
@@ -9,14 +9,9 @@ service EventQueueAdminService {
|
|
|
9
9
|
@cds.persistence.skip
|
|
10
10
|
entity Event as projection on db.Event {
|
|
11
11
|
null as tenant: String,
|
|
12
|
-
null as landscape: String,
|
|
13
|
-
null as space: String,
|
|
14
12
|
*
|
|
15
13
|
} actions {
|
|
16
14
|
action setStatusAndAttempts(
|
|
17
|
-
// TODO: remove tenant as soon as CAP issue is fixed https://github.tools.sap/cap/issues/issues/18445
|
|
18
|
-
@mandatory
|
|
19
|
-
tenant: String,
|
|
20
15
|
status: db.Status,
|
|
21
16
|
@assert.range: [0,100]
|
|
22
17
|
attempts: Integer) returns Event;
|
|
@@ -25,18 +20,14 @@ service EventQueueAdminService {
|
|
|
25
20
|
@cds.persistence.skip
|
|
26
21
|
@readonly
|
|
27
22
|
entity Lock {
|
|
23
|
+
key namespace: String;
|
|
28
24
|
key tenant: String;
|
|
29
25
|
key type: String;
|
|
30
26
|
key subType: String;
|
|
31
|
-
landscape: String;
|
|
32
|
-
space: String;
|
|
33
27
|
ttl: Integer;
|
|
34
28
|
createdAt: Integer;
|
|
35
29
|
} actions {
|
|
36
30
|
action releaseLock(
|
|
37
|
-
// TODO: remove tenant as soon as CAP issue is fixed https://github.tools.sap/cap/issues/issues/18445
|
|
38
|
-
@mandatory
|
|
39
|
-
tenant: String,
|
|
40
31
|
@mandatory
|
|
41
32
|
type: String,
|
|
42
33
|
@mandatory
|
|
@@ -44,6 +35,8 @@ service EventQueueAdminService {
|
|
|
44
35
|
}
|
|
45
36
|
|
|
46
37
|
action publishEvent(
|
|
38
|
+
@mandatory
|
|
39
|
+
namespace: String,
|
|
47
40
|
@mandatory
|
|
48
41
|
tenants: array of String,
|
|
49
42
|
@mandatory
|
|
@@ -52,7 +45,7 @@ service EventQueueAdminService {
|
|
|
52
45
|
subType: String,
|
|
53
46
|
referenceEntity: String,
|
|
54
47
|
referenceEntityKey: String,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
48
|
+
payload: String,
|
|
49
|
+
startAfter: String,
|
|
50
|
+
);
|
|
58
51
|
}
|
|
@@ -15,7 +15,6 @@ module.exports = class AdminService extends cds.ApplicationService {
|
|
|
15
15
|
const { Event: EventService, Lock: LockService } = this.entities;
|
|
16
16
|
const { Event: EventDb } = cds.db.entities("sap.eventqueue");
|
|
17
17
|
const { publishEvent } = this.actions;
|
|
18
|
-
const { landscape, space } = this.getLandscapeAndSpace();
|
|
19
18
|
|
|
20
19
|
this.before("*", (req) => {
|
|
21
20
|
if (!eventQueue.config.enableAdminService) {
|
|
@@ -26,6 +25,10 @@ module.exports = class AdminService extends cds.ApplicationService {
|
|
|
26
25
|
return;
|
|
27
26
|
}
|
|
28
27
|
|
|
28
|
+
if (req.target === LockService) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
29
32
|
const headers = Object.assign({}, req.headers, req.req?.headers);
|
|
30
33
|
const tenant = headers["z-id"] ?? req.data.tenant;
|
|
31
34
|
|
|
@@ -46,8 +49,6 @@ module.exports = class AdminService extends cds.ApplicationService {
|
|
|
46
49
|
}
|
|
47
50
|
const events = await tx.run(req.query);
|
|
48
51
|
return events?.map((event) => {
|
|
49
|
-
event.landscape = landscape;
|
|
50
|
-
event.space = space;
|
|
51
52
|
event.tenant = tenant;
|
|
52
53
|
return event;
|
|
53
54
|
});
|
|
@@ -58,12 +59,7 @@ module.exports = class AdminService extends cds.ApplicationService {
|
|
|
58
59
|
if (!eventQueue.config.redisEnabled) {
|
|
59
60
|
return [];
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
-
return locks.map((lock) => ({
|
|
63
|
-
...lock,
|
|
64
|
-
landscape: landscape,
|
|
65
|
-
space: space,
|
|
66
|
-
}));
|
|
62
|
+
return await distributedLock.getAllLocksRedis();
|
|
67
63
|
});
|
|
68
64
|
|
|
69
65
|
this.on("setStatusAndAttempts", async (req) => {
|
|
@@ -97,17 +93,20 @@ module.exports = class AdminService extends cds.ApplicationService {
|
|
|
97
93
|
});
|
|
98
94
|
|
|
99
95
|
this.on("releaseLock", async (req) => {
|
|
96
|
+
const tenant = req.headers["z-id"];
|
|
100
97
|
cds.log("eventQueue").info("Releasing event-queue lock", req.data);
|
|
101
|
-
const {
|
|
98
|
+
const { type, subType, namespace } = req.data;
|
|
102
99
|
return await cds.tx({ tenant }, async (tx) => {
|
|
103
|
-
return await distributedLock.releaseLock(tx.context, [type, subType].join("##")
|
|
100
|
+
return await distributedLock.releaseLock(tx.context, [namespace, type, subType].join("##"), {
|
|
101
|
+
skipNamespace: true,
|
|
102
|
+
});
|
|
104
103
|
});
|
|
105
104
|
});
|
|
106
105
|
|
|
107
106
|
this.on(publishEvent, async (req) => {
|
|
108
107
|
const logger = cds.log(COMPONENT_NAME);
|
|
109
108
|
try {
|
|
110
|
-
const { type, subType, referenceEntity, referenceEntityKey, payload, startAfter } = req.data;
|
|
109
|
+
const { type, subType, referenceEntity, referenceEntityKey, payload, startAfter, namespace } = req.data;
|
|
111
110
|
const tenants = await publishEventHelper.resolveTenantInfos(req);
|
|
112
111
|
const eventOptions = commonHelper.cleanUndefined({
|
|
113
112
|
type,
|
|
@@ -116,8 +115,9 @@ module.exports = class AdminService extends cds.ApplicationService {
|
|
|
116
115
|
referenceEntityKey,
|
|
117
116
|
payload,
|
|
118
117
|
startAfter,
|
|
118
|
+
namespace,
|
|
119
119
|
});
|
|
120
|
-
const publishInfo = { count: tenants.length, type, subType, tenants: req.data.tenants };
|
|
120
|
+
const publishInfo = { count: tenants.length, type, subType, namespace, tenants: req.data.tenants };
|
|
121
121
|
logger.info("publishing event for tenant(s)", publishInfo);
|
|
122
122
|
for (const tenant of tenants) {
|
|
123
123
|
await cds.tx({ tenant }, async (tx) => {
|
|
@@ -127,25 +127,10 @@ module.exports = class AdminService extends cds.ApplicationService {
|
|
|
127
127
|
logger.info("finished publishing event for tenant(s)", publishInfo);
|
|
128
128
|
} catch (err) {
|
|
129
129
|
logger.error("error publishing event", err);
|
|
130
|
+
req.reject(400, "Error publishing event: " + err.toString());
|
|
130
131
|
}
|
|
131
132
|
});
|
|
132
133
|
|
|
133
134
|
await super.init();
|
|
134
135
|
}
|
|
135
|
-
|
|
136
|
-
getLandscapeAndSpace() {
|
|
137
|
-
const url = cds.requires.db.credentials.url;
|
|
138
|
-
if (!url) {
|
|
139
|
-
return { landscape: "eu10-canary", space: "local-dev" };
|
|
140
|
-
}
|
|
141
|
-
const match = url.match(/https?:\/\/[^.]+\.authentication\.([^.]+)\.hana\.ondemand\.com/);
|
|
142
|
-
const landscape = (match?.[1] ?? "sap") === "sap" ? "eu10-canary" : match?.[1];
|
|
143
|
-
let space = "local-dev";
|
|
144
|
-
try {
|
|
145
|
-
space = JSON.parse(process.env.VCAP_APPLICATION)?.space_name;
|
|
146
|
-
} catch {
|
|
147
|
-
/* empty */
|
|
148
|
-
}
|
|
149
|
-
return { landscape, space };
|
|
150
|
-
}
|
|
151
136
|
};
|