@contember/engine-actions 2.2.0-alpha.2 → 2.2.0-alpha.4
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/CLAUDE.md +58 -0
- package/dist/development/index.cjs +19 -7
- package/dist/development/index.cjs.map +1 -1
- package/dist/development/index.js +19 -7
- package/dist/development/index.js.map +1 -1
- package/dist/development/src/ActionsExecutionContainerHookFactory.cjs +5 -2
- package/dist/development/src/ActionsExecutionContainerHookFactory.cjs.map +1 -1
- package/dist/development/src/ActionsExecutionContainerHookFactory.js +5 -2
- package/dist/development/src/ActionsExecutionContainerHookFactory.js.map +1 -1
- package/dist/development/src/ActionsMetrics.cjs +98 -0
- package/dist/development/src/ActionsMetrics.cjs.map +1 -0
- package/dist/development/src/ActionsMetrics.js +98 -0
- package/dist/development/src/ActionsMetrics.js.map +1 -0
- package/dist/development/src/dispatch/EventDispatcher.cjs +27 -9
- package/dist/development/src/dispatch/EventDispatcher.cjs.map +1 -1
- package/dist/development/src/dispatch/EventDispatcher.js +27 -9
- package/dist/development/src/dispatch/EventDispatcher.js.map +1 -1
- package/dist/development/src/dispatch/EventsRepository.cjs +49 -20
- package/dist/development/src/dispatch/EventsRepository.cjs.map +1 -1
- package/dist/development/src/dispatch/EventsRepository.js +49 -20
- package/dist/development/src/dispatch/EventsRepository.js.map +1 -1
- package/dist/development/src/dispatch/ProjectDispatcher.cjs +21 -13
- package/dist/development/src/dispatch/ProjectDispatcher.cjs.map +1 -1
- package/dist/development/src/dispatch/ProjectDispatcher.js +21 -13
- package/dist/development/src/dispatch/ProjectDispatcher.js.map +1 -1
- package/dist/development/src/dispatch/WebhookTargetHandler.cjs +29 -2
- package/dist/development/src/dispatch/WebhookTargetHandler.cjs.map +1 -1
- package/dist/development/src/dispatch/WebhookTargetHandler.js +29 -2
- package/dist/development/src/dispatch/WebhookTargetHandler.js.map +1 -1
- package/dist/development/src/graphql/resolvers/ResolversFactory.cjs +1 -1
- package/dist/development/src/graphql/resolvers/ResolversFactory.cjs.map +1 -1
- package/dist/development/src/graphql/resolvers/ResolversFactory.js +1 -1
- package/dist/development/src/graphql/resolvers/ResolversFactory.js.map +1 -1
- package/dist/development/src/graphql/resolvers/mutation/SetVariablesMutationResolver.cjs +1 -1
- package/dist/development/src/graphql/resolvers/mutation/SetVariablesMutationResolver.cjs.map +1 -1
- package/dist/development/src/graphql/resolvers/mutation/SetVariablesMutationResolver.js +1 -1
- package/dist/development/src/graphql/resolvers/mutation/SetVariablesMutationResolver.js.map +1 -1
- package/dist/development/src/graphql/resolvers/query/EventsQueryResolver.cjs +1 -1
- package/dist/development/src/graphql/resolvers/query/EventsQueryResolver.cjs.map +1 -1
- package/dist/development/src/graphql/resolvers/query/EventsQueryResolver.js +1 -1
- package/dist/development/src/graphql/resolvers/query/EventsQueryResolver.js.map +1 -1
- package/dist/development/src/graphql/resolvers/query/VariablesQueryResolver.cjs +1 -4
- package/dist/development/src/graphql/resolvers/query/VariablesQueryResolver.cjs.map +1 -1
- package/dist/development/src/graphql/resolvers/query/VariablesQueryResolver.js +1 -4
- package/dist/development/src/graphql/resolvers/query/VariablesQueryResolver.js.map +1 -1
- package/dist/development/src/graphql/schema/actions.graphql.cjs +20 -8
- package/dist/development/src/graphql/schema/actions.graphql.cjs.map +1 -1
- package/dist/development/src/graphql/schema/actions.graphql.js +20 -8
- package/dist/development/src/graphql/schema/actions.graphql.js.map +1 -1
- package/dist/development/src/model/VariablesManager.cjs +54 -5
- package/dist/development/src/model/VariablesManager.cjs.map +1 -1
- package/dist/development/src/model/VariablesManager.js +54 -5
- package/dist/development/src/model/VariablesManager.js.map +1 -1
- package/dist/development/src/triggers/TriggerPayloadPersister.cjs +3 -1
- package/dist/development/src/triggers/TriggerPayloadPersister.cjs.map +1 -1
- package/dist/development/src/triggers/TriggerPayloadPersister.js +3 -1
- package/dist/development/src/triggers/TriggerPayloadPersister.js.map +1 -1
- package/dist/production/index.cjs +19 -7
- package/dist/production/index.cjs.map +1 -1
- package/dist/production/index.js +19 -7
- package/dist/production/index.js.map +1 -1
- package/dist/production/src/ActionsExecutionContainerHookFactory.cjs +5 -2
- package/dist/production/src/ActionsExecutionContainerHookFactory.cjs.map +1 -1
- package/dist/production/src/ActionsExecutionContainerHookFactory.js +5 -2
- package/dist/production/src/ActionsExecutionContainerHookFactory.js.map +1 -1
- package/dist/production/src/ActionsMetrics.cjs +98 -0
- package/dist/production/src/ActionsMetrics.cjs.map +1 -0
- package/dist/production/src/ActionsMetrics.js +98 -0
- package/dist/production/src/ActionsMetrics.js.map +1 -0
- package/dist/production/src/dispatch/EventDispatcher.cjs +27 -9
- package/dist/production/src/dispatch/EventDispatcher.cjs.map +1 -1
- package/dist/production/src/dispatch/EventDispatcher.js +27 -9
- package/dist/production/src/dispatch/EventDispatcher.js.map +1 -1
- package/dist/production/src/dispatch/EventsRepository.cjs +49 -20
- package/dist/production/src/dispatch/EventsRepository.cjs.map +1 -1
- package/dist/production/src/dispatch/EventsRepository.js +49 -20
- package/dist/production/src/dispatch/EventsRepository.js.map +1 -1
- package/dist/production/src/dispatch/ProjectDispatcher.cjs +21 -13
- package/dist/production/src/dispatch/ProjectDispatcher.cjs.map +1 -1
- package/dist/production/src/dispatch/ProjectDispatcher.js +21 -13
- package/dist/production/src/dispatch/ProjectDispatcher.js.map +1 -1
- package/dist/production/src/dispatch/WebhookTargetHandler.cjs +29 -2
- package/dist/production/src/dispatch/WebhookTargetHandler.cjs.map +1 -1
- package/dist/production/src/dispatch/WebhookTargetHandler.js +29 -2
- package/dist/production/src/dispatch/WebhookTargetHandler.js.map +1 -1
- package/dist/production/src/graphql/resolvers/ResolversFactory.cjs +1 -1
- package/dist/production/src/graphql/resolvers/ResolversFactory.cjs.map +1 -1
- package/dist/production/src/graphql/resolvers/ResolversFactory.js +1 -1
- package/dist/production/src/graphql/resolvers/ResolversFactory.js.map +1 -1
- package/dist/production/src/graphql/resolvers/mutation/SetVariablesMutationResolver.cjs +1 -1
- package/dist/production/src/graphql/resolvers/mutation/SetVariablesMutationResolver.cjs.map +1 -1
- package/dist/production/src/graphql/resolvers/mutation/SetVariablesMutationResolver.js +1 -1
- package/dist/production/src/graphql/resolvers/mutation/SetVariablesMutationResolver.js.map +1 -1
- package/dist/production/src/graphql/resolvers/query/EventsQueryResolver.cjs +1 -1
- package/dist/production/src/graphql/resolvers/query/EventsQueryResolver.cjs.map +1 -1
- package/dist/production/src/graphql/resolvers/query/EventsQueryResolver.js +1 -1
- package/dist/production/src/graphql/resolvers/query/EventsQueryResolver.js.map +1 -1
- package/dist/production/src/graphql/resolvers/query/VariablesQueryResolver.cjs +1 -4
- package/dist/production/src/graphql/resolvers/query/VariablesQueryResolver.cjs.map +1 -1
- package/dist/production/src/graphql/resolvers/query/VariablesQueryResolver.js +1 -4
- package/dist/production/src/graphql/resolvers/query/VariablesQueryResolver.js.map +1 -1
- package/dist/production/src/graphql/schema/actions.graphql.cjs +20 -8
- package/dist/production/src/graphql/schema/actions.graphql.cjs.map +1 -1
- package/dist/production/src/graphql/schema/actions.graphql.js +20 -8
- package/dist/production/src/graphql/schema/actions.graphql.js.map +1 -1
- package/dist/production/src/model/VariablesManager.cjs +54 -5
- package/dist/production/src/model/VariablesManager.cjs.map +1 -1
- package/dist/production/src/model/VariablesManager.js +54 -5
- package/dist/production/src/model/VariablesManager.js.map +1 -1
- package/dist/production/src/triggers/TriggerPayloadPersister.cjs +3 -1
- package/dist/production/src/triggers/TriggerPayloadPersister.cjs.map +1 -1
- package/dist/production/src/triggers/TriggerPayloadPersister.js +3 -1
- package/dist/production/src/triggers/TriggerPayloadPersister.js.map +1 -1
- package/dist/tests/cases/unit/actionsMetrics.test.d.ts +2 -0
- package/dist/tests/cases/unit/actionsMetrics.test.d.ts.map +1 -0
- package/dist/tests/cases/unit/eventsRepository.test.d.ts +2 -0
- package/dist/tests/cases/unit/eventsRepository.test.d.ts.map +1 -0
- package/dist/tests/cases/unit/variablesManager.test.d.ts +2 -0
- package/dist/tests/cases/unit/variablesManager.test.d.ts.map +1 -0
- package/dist/tests/src/event.d.ts +4 -0
- package/dist/tests/src/event.d.ts.map +1 -0
- package/dist/tests/src/gql.d.ts.map +1 -1
- package/dist/tests/src/uuid.d.ts.map +1 -1
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/dist/types/ActionsExecutionContainerHookFactory.d.ts +3 -1
- package/dist/types/ActionsExecutionContainerHookFactory.d.ts.map +1 -1
- package/dist/types/ActionsMetrics.d.ts +59 -0
- package/dist/types/ActionsMetrics.d.ts.map +1 -0
- package/dist/types/ListenerStoreProvider.d.ts.map +1 -1
- package/dist/types/authorization/ActionsPermissionsFactory.d.ts.map +1 -1
- package/dist/types/dispatch/DispatchWorkerSupervisor.d.ts.map +1 -1
- package/dist/types/dispatch/EventDispatcher.d.ts +12 -3
- package/dist/types/dispatch/EventDispatcher.d.ts.map +1 -1
- package/dist/types/dispatch/EventsRepository.d.ts +14 -2
- package/dist/types/dispatch/EventsRepository.d.ts.map +1 -1
- package/dist/types/dispatch/LazyDispatchWorker.d.ts.map +1 -1
- package/dist/types/dispatch/ProjectDispatcher.d.ts +5 -2
- package/dist/types/dispatch/ProjectDispatcher.d.ts.map +1 -1
- package/dist/types/dispatch/TargetHandlerResolver.d.ts.map +1 -1
- package/dist/types/dispatch/WebhookFetcher.d.ts.map +1 -1
- package/dist/types/dispatch/WebhookTargetHandler.d.ts.map +1 -1
- package/dist/types/graphql/http/ActionsApiMiddlewareFactory.d.ts.map +1 -1
- package/dist/types/graphql/http/ActionsContextResolver.d.ts.map +1 -1
- package/dist/types/graphql/http/ActionsGraphQLHandlerFactory.d.ts.map +1 -1
- package/dist/types/graphql/http/ActionsWebsocketControllerFactory.d.ts.map +1 -1
- package/dist/types/graphql/resolvers/ResolversFactory.d.ts.map +1 -1
- package/dist/types/graphql/resolvers/mutation/ProcessBatchMutationResolver.d.ts.map +1 -1
- package/dist/types/graphql/resolvers/mutation/RetryEventMutationResolver.d.ts.map +1 -1
- package/dist/types/graphql/resolvers/mutation/SetVariablesMutationResolver.d.ts.map +1 -1
- package/dist/types/graphql/resolvers/mutation/StopEventMutationResolver.d.ts.map +1 -1
- package/dist/types/graphql/resolvers/query/EventsQueryResolver.d.ts.map +1 -1
- package/dist/types/graphql/resolvers/query/VariablesQueryResolver.d.ts +1 -4
- package/dist/types/graphql/resolvers/query/VariablesQueryResolver.d.ts.map +1 -1
- package/dist/types/graphql/schema/actions.graphql.d.ts.map +1 -1
- package/dist/types/graphql/schema/index.d.ts +27 -17
- package/dist/types/graphql/schema/index.d.ts.map +1 -1
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/model/EventByIdQuery.d.ts.map +1 -1
- package/dist/types/model/EventsInProcessingQuery.d.ts.map +1 -1
- package/dist/types/model/EventsToProcessQuery.d.ts.map +1 -1
- package/dist/types/model/FailedEventsQuery.d.ts.map +1 -1
- package/dist/types/model/SetVariableCommand.d.ts.map +1 -1
- package/dist/types/model/VariablesManager.d.ts +16 -3
- package/dist/types/model/VariablesManager.d.ts.map +1 -1
- package/dist/types/model/VariablesQuery.d.ts.map +1 -1
- package/dist/types/triggers/TriggerHandler.d.ts.map +1 -1
- package/dist/types/triggers/TriggerIndirectChangesFetcher.d.ts.map +1 -1
- package/dist/types/triggers/TriggerListenersBuilder.d.ts.map +1 -1
- package/dist/types/triggers/TriggerListenersFactory.d.ts.map +1 -1
- package/dist/types/triggers/TriggerListenersStore.d.ts.map +1 -1
- package/dist/types/triggers/TriggerPayloadBuilder.d.ts.map +1 -1
- package/dist/types/triggers/TriggerPayloadManager.d.ts.map +1 -1
- package/dist/types/triggers/TriggerPayloadPersister.d.ts +3 -1
- package/dist/types/triggers/TriggerPayloadPersister.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/utils/map.d.ts.map +1 -1
- package/dist/types/utils/notifyChannel.d.ts.map +1 -1
- package/graphql.codegen.yml +1 -1
- package/package.json +18 -16
- package/src/ActionsExecutionContainerHookFactory.ts +6 -0
- package/src/ActionsMetrics.ts +135 -0
- package/src/dispatch/EventDispatcher.ts +36 -11
- package/src/dispatch/EventsRepository.ts +68 -26
- package/src/dispatch/ProjectDispatcher.ts +27 -11
- package/src/dispatch/WebhookTargetHandler.ts +36 -1
- package/src/graphql/resolvers/ResolversFactory.ts +1 -1
- package/src/graphql/resolvers/mutation/SetVariablesMutationResolver.ts +1 -1
- package/src/graphql/resolvers/query/EventsQueryResolver.ts +1 -1
- package/src/graphql/resolvers/query/VariablesQueryResolver.ts +1 -4
- package/src/graphql/schema/actions.graphql.ts +20 -8
- package/src/graphql/schema/index.ts +30 -17
- package/src/index.ts +27 -5
- package/src/model/VariablesManager.ts +66 -6
- package/src/triggers/TriggerPayloadPersister.ts +6 -0
- package/tests/cases/unit/actionsMetrics.test.ts +76 -0
- package/tests/cases/unit/eventsRepository.test.ts +136 -0
- package/tests/cases/unit/variablesManager.test.ts +154 -0
- package/tests/cases/unit/webhookHandler.test.ts +80 -24
- package/tests/src/event.ts +27 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../../../src/utils/map.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,SAAS,CAAC,
|
|
1
|
+
{"version":3,"file":"map.d.ts","sourceRoot":"","sources":["../../../src/utils/map.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,GAAI,CAAC,EAAE,CAAC,EAAE,CAAC,SAAS,CAAC,OACvC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,OACT,CAAC,gBACQ,MAAM,CAAC,KACnB,CAQF,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notifyChannel.d.ts","sourceRoot":"","sources":["../../../src/utils/notifyChannel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAE5C,eAAO,MAAM,mBAAmB,kBAAkB,CAAA;AAClD,eAAO,MAAM,MAAM,
|
|
1
|
+
{"version":3,"file":"notifyChannel.d.ts","sourceRoot":"","sources":["../../../src/utils/notifyChannel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAE5C,eAAO,MAAM,mBAAmB,kBAAkB,CAAA;AAClD,eAAO,MAAM,MAAM,OAAc,MAAM,eAAe,MAAM,kBAE3D,CAAA"}
|
package/graphql.codegen.yml
CHANGED
package/package.json
CHANGED
|
@@ -22,24 +22,26 @@
|
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@contember/authorization": "2.
|
|
26
|
-
"@contember/database": "2.
|
|
27
|
-
"@contember/database-migrations": "2.
|
|
28
|
-
"@contember/engine-common": "2.
|
|
29
|
-
"@contember/engine-content-api": "2.
|
|
30
|
-
"@contember/engine-http": "2.
|
|
31
|
-
"@contember/engine-plugins": "2.
|
|
32
|
-
"@contember/engine-system-api": "2.
|
|
33
|
-
"@contember/engine-tenant-api": "2.
|
|
34
|
-
"@contember/graphql-utils": "2.
|
|
35
|
-
"@contember/logger": "2.
|
|
36
|
-
"@contember/schema": "2.
|
|
37
|
-
"@contember/schema-utils": "2.
|
|
38
|
-
"@contember/typesafe": "2.
|
|
25
|
+
"@contember/authorization": "2.2.0-alpha.4",
|
|
26
|
+
"@contember/database": "2.2.0-alpha.4",
|
|
27
|
+
"@contember/database-migrations": "2.2.0-alpha.4",
|
|
28
|
+
"@contember/engine-common": "2.2.0-alpha.4",
|
|
29
|
+
"@contember/engine-content-api": "2.2.0-alpha.4",
|
|
30
|
+
"@contember/engine-http": "2.2.0-alpha.4",
|
|
31
|
+
"@contember/engine-plugins": "2.2.0-alpha.4",
|
|
32
|
+
"@contember/engine-system-api": "2.2.0-alpha.4",
|
|
33
|
+
"@contember/engine-tenant-api": "2.2.0-alpha.4",
|
|
34
|
+
"@contember/graphql-utils": "2.2.0-alpha.4",
|
|
35
|
+
"@contember/logger": "2.2.0-alpha.4",
|
|
36
|
+
"@contember/schema": "2.2.0-alpha.4",
|
|
37
|
+
"@contember/schema-utils": "2.2.0-alpha.4",
|
|
38
|
+
"@contember/typesafe": "2.2.0-alpha.4",
|
|
39
39
|
"@graphql-tools/schema": "^10.0.31",
|
|
40
|
-
"graphql-tag": "^2.12.6"
|
|
40
|
+
"graphql-tag": "^2.12.6",
|
|
41
|
+
"prom-client": "^12.0.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
44
|
+
"@contember/database-tester": "2.2.0-alpha.4",
|
|
43
45
|
"@types/bun": "^1.3.10",
|
|
44
46
|
"@types/ws": "^8.18.1",
|
|
45
47
|
"graphql": "^16.13.1"
|
|
@@ -49,7 +51,7 @@
|
|
|
49
51
|
"pg": "^8.20.0"
|
|
50
52
|
},
|
|
51
53
|
"type": "module",
|
|
52
|
-
"version": "2.2.0-alpha.
|
|
54
|
+
"version": "2.2.0-alpha.4",
|
|
53
55
|
"repository": {
|
|
54
56
|
"type": "git",
|
|
55
57
|
"url": "git+https://github.com/contember/contember.git",
|
|
@@ -3,10 +3,14 @@ import { ListenerStoreProvider } from './ListenerStoreProvider.js'
|
|
|
3
3
|
import { TriggerPayloadBuilder } from './triggers/TriggerPayloadBuilder.js'
|
|
4
4
|
import { TriggerPayloadPersister } from './triggers/TriggerPayloadPersister.js'
|
|
5
5
|
import { ExecutionContainerHook } from '@contember/engine-content-api'
|
|
6
|
+
import { ActionsMetrics } from './ActionsMetrics.js'
|
|
6
7
|
|
|
7
8
|
export class ActionsExecutionContainerHookFactory {
|
|
8
9
|
constructor(
|
|
9
10
|
private readonly listenerStoreProvider: ListenerStoreProvider,
|
|
11
|
+
// Resolved lazily: the metrics singleton lives in the master container and is wired up when the
|
|
12
|
+
// dispatch worker boots, which precedes any content request that could enqueue an event.
|
|
13
|
+
private readonly metricsProvider: () => ActionsMetrics | undefined,
|
|
10
14
|
) {
|
|
11
15
|
}
|
|
12
16
|
|
|
@@ -18,6 +22,7 @@ export class ActionsExecutionContainerHookFactory {
|
|
|
18
22
|
mapperFactory,
|
|
19
23
|
{ whereBuilder, schema, pathFactory, systemSchema, providers, stage, project, schemaMeta, joinBuilder, userInfo, triggeredActionsCollector },
|
|
20
24
|
) => {
|
|
25
|
+
const projectMetrics = this.metricsProvider()?.forProject(project.slug)
|
|
21
26
|
mapperFactory.hooks.push(mapper => {
|
|
22
27
|
const triggerPayloadPersister = new TriggerPayloadPersister(
|
|
23
28
|
mapper,
|
|
@@ -29,6 +34,7 @@ export class ActionsExecutionContainerHookFactory {
|
|
|
29
34
|
mapper.identityId,
|
|
30
35
|
userInfo,
|
|
31
36
|
triggeredActionsCollector,
|
|
37
|
+
projectMetrics,
|
|
32
38
|
)
|
|
33
39
|
const triggerPayloadBuilder = new TriggerPayloadBuilder(mapper)
|
|
34
40
|
const payloadManager = new TriggerPayloadManager(triggerPayloadBuilder, triggerPayloadPersister)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import prom, { Registry } from 'prom-client'
|
|
2
|
+
|
|
3
|
+
const LABEL_PROJECT = 'contember_project'
|
|
4
|
+
type Label = typeof LABEL_PROJECT
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prometheus metrics for the Actions dispatch subsystem.
|
|
8
|
+
*
|
|
9
|
+
* Two audiences, deliberately kept as cheap in-memory counters/gauges (no table polling):
|
|
10
|
+
* - system / operator — is the dispatch loop alive and flowing, or stuck?
|
|
11
|
+
* `workerHeartbeat` (liveness; works even when the queue is empty) + `workerCrashed`.
|
|
12
|
+
* - userland — are webhook deliveries failing?
|
|
13
|
+
* `eventsSucceeded` / `deliveryAttemptsFailed` / `eventsFailed`.
|
|
14
|
+
*
|
|
15
|
+
* Counting units matter: a *delivery attempt* and an *event* are different. A success is always
|
|
16
|
+
* terminal (one event = one successful attempt), but a failure splits — a failed attempt may be
|
|
17
|
+
* retried many times, while a terminally failed event gives up once. Hence the asymmetric set:
|
|
18
|
+
* one success counter, but both a per-attempt failure counter and a terminal-event failure counter.
|
|
19
|
+
*
|
|
20
|
+
* Queue backlog is *estimated* as a trend from the counters:
|
|
21
|
+
* enqueued − succeeded − failed (compare rates; the absolute value drifts across restarts and
|
|
22
|
+
* ignores manual retry/stop, so for an exact depth query the `actions_event` table instead).
|
|
23
|
+
*
|
|
24
|
+
* Labelled only by `contember_project` to keep cardinality bounded; per-target / per-trigger
|
|
25
|
+
* detail belongs in the table-backed userland view, not in Prometheus.
|
|
26
|
+
*/
|
|
27
|
+
export class ActionsMetrics {
|
|
28
|
+
/** Events written to the queue (inflow). Unit: event. */
|
|
29
|
+
public readonly eventsEnqueued: prom.Counter<Label>
|
|
30
|
+
/** Events delivered successfully (terminal). Unit: event (= successful attempt). */
|
|
31
|
+
public readonly eventsSucceeded: prom.Counter<Label>
|
|
32
|
+
/** Failed webhook delivery attempts, including ones that will be retried. Unit: attempt. */
|
|
33
|
+
public readonly deliveryAttemptsFailed: prom.Counter<Label>
|
|
34
|
+
/** Events that reached a terminal failed state (retries exhausted or unknown target). Unit: event. */
|
|
35
|
+
public readonly eventsFailed: prom.Counter<Label>
|
|
36
|
+
/** Unix timestamp (seconds) of the last dispatch-loop iteration; staleness ⇒ stuck/dead worker. */
|
|
37
|
+
public readonly workerHeartbeat: prom.Gauge<Label>
|
|
38
|
+
/** Dispatch-loop crashes (each is auto-restarted by the supervisor). */
|
|
39
|
+
public readonly workerCrashed: prom.Counter<Label>
|
|
40
|
+
|
|
41
|
+
constructor(registry: Registry) {
|
|
42
|
+
const labelNames: Label[] = [LABEL_PROJECT]
|
|
43
|
+
this.eventsEnqueued = new prom.Counter({
|
|
44
|
+
registers: [registry],
|
|
45
|
+
name: 'contember_actions_events_enqueued_total',
|
|
46
|
+
help: 'Total number of action events written to the queue (inflow), by contember_project.',
|
|
47
|
+
labelNames,
|
|
48
|
+
})
|
|
49
|
+
this.eventsSucceeded = new prom.Counter({
|
|
50
|
+
registers: [registry],
|
|
51
|
+
name: 'contember_actions_events_succeeded_total',
|
|
52
|
+
help: 'Total number of action events delivered successfully, by contember_project.',
|
|
53
|
+
labelNames,
|
|
54
|
+
})
|
|
55
|
+
this.deliveryAttemptsFailed = new prom.Counter({
|
|
56
|
+
registers: [registry],
|
|
57
|
+
name: 'contember_actions_delivery_attempts_failed_total',
|
|
58
|
+
help: 'Total number of failed webhook delivery attempts (including attempts that will be retried), by contember_project.',
|
|
59
|
+
labelNames,
|
|
60
|
+
})
|
|
61
|
+
this.eventsFailed = new prom.Counter({
|
|
62
|
+
registers: [registry],
|
|
63
|
+
name: 'contember_actions_events_failed_total',
|
|
64
|
+
help: 'Total number of action events that reached a terminal failed state (retries exhausted or unknown target), by contember_project.',
|
|
65
|
+
labelNames,
|
|
66
|
+
})
|
|
67
|
+
this.workerHeartbeat = new prom.Gauge({
|
|
68
|
+
registers: [registry],
|
|
69
|
+
name: 'contember_actions_worker_heartbeat_timestamp_seconds',
|
|
70
|
+
help: 'Unix timestamp of the last dispatch-loop iteration per contember_project; staleness indicates a stuck or dead worker.',
|
|
71
|
+
labelNames,
|
|
72
|
+
})
|
|
73
|
+
this.workerCrashed = new prom.Counter({
|
|
74
|
+
registers: [registry],
|
|
75
|
+
name: 'contember_actions_worker_crashed_total',
|
|
76
|
+
help: 'Total number of dispatch-loop crashes per contember_project (each is auto-restarted by the supervisor).',
|
|
77
|
+
labelNames,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public forProject(projectSlug: string): ProjectActionsMetrics {
|
|
82
|
+
return new ProjectActionsMetrics(this, projectSlug)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A view of {@link ActionsMetrics} bound to a single project, so call sites don't repeat the label.
|
|
88
|
+
*/
|
|
89
|
+
export class ProjectActionsMetrics {
|
|
90
|
+
private readonly labels: { [LABEL_PROJECT]: string }
|
|
91
|
+
|
|
92
|
+
constructor(
|
|
93
|
+
private readonly metrics: ActionsMetrics,
|
|
94
|
+
private readonly projectSlug: string,
|
|
95
|
+
) {
|
|
96
|
+
this.labels = { [LABEL_PROJECT]: projectSlug }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public enqueued(count: number): void {
|
|
100
|
+
if (count > 0) {
|
|
101
|
+
this.metrics.eventsEnqueued.inc(this.labels, count)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public succeeded(count: number): void {
|
|
106
|
+
if (count > 0) {
|
|
107
|
+
this.metrics.eventsSucceeded.inc(this.labels, count)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public deliveryAttemptFailed(count: number): void {
|
|
112
|
+
if (count > 0) {
|
|
113
|
+
this.metrics.deliveryAttemptsFailed.inc(this.labels, count)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public failed(count: number): void {
|
|
118
|
+
if (count > 0) {
|
|
119
|
+
this.metrics.eventsFailed.inc(this.labels, count)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
public heartbeat(): void {
|
|
124
|
+
this.metrics.workerHeartbeat.set(this.labels, Date.now() / 1000)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public crashed(): void {
|
|
128
|
+
this.metrics.workerCrashed.inc(this.labels, 1)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Drop the per-project heartbeat series when the worker stops, so a removed project doesn't look stuck. */
|
|
132
|
+
public dispose(): void {
|
|
133
|
+
this.metrics.workerHeartbeat.remove(this.projectSlug)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -10,11 +10,18 @@ type ProcessBatchArgs = {
|
|
|
10
10
|
db: DatabaseContext
|
|
11
11
|
contentSchemaResolver: ContentSchemaResolver
|
|
12
12
|
logger: Logger
|
|
13
|
+
project: { slug: string }
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
type ProcessBatchResult = {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/** Events delivered successfully (terminal). */
|
|
18
|
+
succeeded: number
|
|
19
|
+
/** Failed delivery attempts that will be retried. */
|
|
20
|
+
retried: number
|
|
21
|
+
/** Events terminally failed after a delivery attempt (retries exhausted). */
|
|
22
|
+
failedAfterAttempt: number
|
|
23
|
+
/** Events terminally failed without a delivery attempt (target missing from schema). */
|
|
24
|
+
failedUnknownTarget: number
|
|
18
25
|
backoffMs: number | undefined
|
|
19
26
|
}
|
|
20
27
|
|
|
@@ -26,16 +33,22 @@ export class EventDispatcher {
|
|
|
26
33
|
) {
|
|
27
34
|
}
|
|
28
35
|
|
|
29
|
-
async processBatch({ db, contentSchemaResolver, logger }: ProcessBatchArgs): Promise<ProcessBatchResult> {
|
|
36
|
+
async processBatch({ db, contentSchemaResolver, logger, project }: ProcessBatchArgs): Promise<ProcessBatchResult> {
|
|
30
37
|
const schema = (await contentSchemaResolver.getSchema({ db, normalize: true })).schema
|
|
31
|
-
const batch = await this.eventsRepository.fetchBatch(schema.actions, db.client)
|
|
32
38
|
const batchId = Math.random().toString().substring(2)
|
|
33
39
|
const batchLogger = logger.child({ loc: 'Actions.EventDispatcher', batchId })
|
|
40
|
+
const batch = await this.eventsRepository.fetchBatch(schema.actions, db.client, batchLogger)
|
|
34
41
|
if (!batch.ok) {
|
|
35
42
|
batchLogger.debug('Nothing to process', {
|
|
36
43
|
backoffMs: batch.backoffMs ?? 'undefined',
|
|
37
44
|
})
|
|
38
|
-
return {
|
|
45
|
+
return {
|
|
46
|
+
succeeded: 0,
|
|
47
|
+
retried: 0,
|
|
48
|
+
failedAfterAttempt: 0,
|
|
49
|
+
failedUnknownTarget: batch.unknownTargetFailed,
|
|
50
|
+
backoffMs: batch.backoffMs,
|
|
51
|
+
}
|
|
39
52
|
}
|
|
40
53
|
const { target, events } = batch
|
|
41
54
|
try {
|
|
@@ -43,20 +56,32 @@ export class EventDispatcher {
|
|
|
43
56
|
batchLogger.debug('Processing started', {
|
|
44
57
|
events: batch.events.map(it => it.id),
|
|
45
58
|
})
|
|
46
|
-
const variables = await this.variablesManager.fetchVariables(db)
|
|
59
|
+
const variables = await this.variablesManager.fetchVariables(db, project.slug)
|
|
47
60
|
const handledEvents = await handler.handle({ target, events, logger: batchLogger, variables })
|
|
48
|
-
const
|
|
49
|
-
batchLogger.debug('Processing done', { succeed, failed })
|
|
50
|
-
return {
|
|
61
|
+
const { succeeded, retried, failed } = await this.eventsRepository.persistProcessed(db.client, handledEvents, batchLogger)
|
|
62
|
+
batchLogger.debug('Processing done', { succeed: succeeded, failed })
|
|
63
|
+
return {
|
|
64
|
+
succeeded,
|
|
65
|
+
retried,
|
|
66
|
+
failedAfterAttempt: failed,
|
|
67
|
+
failedUnknownTarget: batch.unknownTargetFailed,
|
|
68
|
+
backoffMs: 0,
|
|
69
|
+
}
|
|
51
70
|
} catch (e) {
|
|
52
71
|
logger.error(e, { loc: 'EventDispatcher', batchId })
|
|
53
72
|
const failedEvents = events.map((it): HandledEvent => ({
|
|
54
73
|
row: it,
|
|
55
74
|
result: { ok: false, errorMessage: `Internal error` },
|
|
56
75
|
}))
|
|
57
|
-
await this.eventsRepository.persistProcessed(db.client, failedEvents)
|
|
76
|
+
const { succeeded, retried, failed } = await this.eventsRepository.persistProcessed(db.client, failedEvents, batchLogger)
|
|
58
77
|
|
|
59
|
-
return {
|
|
78
|
+
return {
|
|
79
|
+
succeeded,
|
|
80
|
+
retried,
|
|
81
|
+
failedAfterAttempt: failed,
|
|
82
|
+
failedUnknownTarget: batch.unknownTargetFailed,
|
|
83
|
+
backoffMs: 0,
|
|
84
|
+
}
|
|
60
85
|
}
|
|
61
86
|
}
|
|
62
87
|
}
|
|
@@ -3,6 +3,7 @@ import { EventRow, HandledEvent } from './types.js'
|
|
|
3
3
|
import { Actions } from '@contember/schema'
|
|
4
4
|
import { eventsToProcessSpecification, eventsToProcessStateSpecification } from '../model/EventsToProcessSpecification.js'
|
|
5
5
|
import { notify } from '../utils/notifyChannel.js'
|
|
6
|
+
import { Logger } from '@contember/logger'
|
|
6
7
|
|
|
7
8
|
const ACK_TIMEOUT_MS = 1_000 * 60 * 10 // 10 minutes
|
|
8
9
|
const DEFAULT_REPEAT_INTERVAL_MS = 5_000 // 5 seconds
|
|
@@ -10,19 +11,32 @@ const DEFAULT_MAX_ATTEMPTS = 10
|
|
|
10
11
|
const DEFAULT_BATCH_SIZE = 1
|
|
11
12
|
|
|
12
13
|
export class EventsRepository {
|
|
13
|
-
public async fetchBatch(actions: Actions.Schema, db: Client): Promise<FetchBatchResult> {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
public async fetchBatch(actions: Actions.Schema, db: Client, logger: Logger): Promise<FetchBatchResult> {
|
|
15
|
+
// Events referencing a target that no longer exists in the schema are terminally failed and
|
|
16
|
+
// skipped over; count them so the caller can report them as terminal failures.
|
|
17
|
+
let unknownTargetFailed = 0
|
|
18
|
+
while (true) {
|
|
19
|
+
const primaryEvent = (await this.fetchInternal(db, 1))[0]
|
|
20
|
+
if (!primaryEvent) {
|
|
21
|
+
return { ok: false, backoffMs: await this.fetchBackOff(db), unknownTargetFailed }
|
|
22
|
+
}
|
|
23
|
+
const target = actions.targets[primaryEvent.target]
|
|
24
|
+
if (!target) {
|
|
25
|
+
if (await this.markFailedOnUnknownTarget(db, primaryEvent.target, primaryEvent.id)) {
|
|
26
|
+
unknownTargetFailed++
|
|
27
|
+
// Never retried: the schema has to name the target again before such an event can be delivered.
|
|
28
|
+
logger.error('Action event failed permanently, its target is not in the schema', {
|
|
29
|
+
eventId: primaryEvent.id,
|
|
30
|
+
target: primaryEvent.target,
|
|
31
|
+
trigger: primaryEvent.trigger,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
const batchSize = (target.batchSize ?? DEFAULT_BATCH_SIZE) - 1
|
|
37
|
+
const batch = batchSize > 0 ? await this.fetchInternal(db, batchSize) : []
|
|
38
|
+
return { ok: true, events: [primaryEvent, ...batch], target, unknownTargetFailed }
|
|
22
39
|
}
|
|
23
|
-
const batchSize = (target.batchSize ?? DEFAULT_BATCH_SIZE) - 1
|
|
24
|
-
const batch = batchSize > 0 ? await this.fetchInternal(db, batchSize) : []
|
|
25
|
-
return { ok: true, events: [primaryEvent, ...batch], target }
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
private async fetchBackOff(db: Client): Promise<number | undefined> {
|
|
@@ -60,18 +74,34 @@ export class EventsRepository {
|
|
|
60
74
|
.execute(db)
|
|
61
75
|
}
|
|
62
76
|
|
|
63
|
-
public async persistProcessed(db: Client, events: HandledEvent[]): Promise<
|
|
77
|
+
public async persistProcessed(db: Client, events: HandledEvent[], logger: Logger): Promise<PersistProcessedResult> {
|
|
64
78
|
return await db.transaction(async trx => {
|
|
65
79
|
await trx.query(Connection.REPEATABLE_READ)
|
|
66
80
|
const succeed = events.filter(it => it.result.ok)
|
|
67
81
|
await this.markSucceed(trx, succeed.map(it => it.row))
|
|
68
82
|
|
|
83
|
+
let retried = 0
|
|
84
|
+
let failed = 0
|
|
69
85
|
for (const event of events) {
|
|
70
86
|
if (!event.result.ok) {
|
|
71
|
-
await this.markFailed(trx, event)
|
|
87
|
+
const terminal = await this.markFailed(trx, event)
|
|
88
|
+
if (terminal) {
|
|
89
|
+
failed++
|
|
90
|
+
// Nothing else reports a given-up event; the target is named, never described by URL or headers.
|
|
91
|
+
logger.error('Action event failed permanently, no attempts left', {
|
|
92
|
+
eventId: event.row.id,
|
|
93
|
+
target: event.row.target,
|
|
94
|
+
trigger: event.row.trigger,
|
|
95
|
+
attempts: event.row.num_retries + 1,
|
|
96
|
+
code: event.result.code,
|
|
97
|
+
errorMessage: event.result.errorMessage,
|
|
98
|
+
})
|
|
99
|
+
} else {
|
|
100
|
+
retried++
|
|
101
|
+
}
|
|
72
102
|
}
|
|
73
103
|
}
|
|
74
|
-
return
|
|
104
|
+
return { succeeded: succeed.length, retried, failed }
|
|
75
105
|
})
|
|
76
106
|
}
|
|
77
107
|
|
|
@@ -124,42 +154,44 @@ export class EventsRepository {
|
|
|
124
154
|
.execute(db)
|
|
125
155
|
}
|
|
126
156
|
|
|
127
|
-
|
|
157
|
+
/** Returns `true` when the event reached a terminal `failed` state, `false` when it will be retried. */
|
|
158
|
+
private async markFailed(db: Client, event: HandledEvent): Promise<boolean> {
|
|
128
159
|
const numRetries = event.row.num_retries + 1
|
|
129
160
|
const now = new Date()
|
|
130
161
|
const nextAttempt = new Date()
|
|
131
162
|
nextAttempt.setTime(
|
|
132
163
|
nextAttempt.getTime() + (event.target?.initialRepeatIntervalMs ?? DEFAULT_REPEAT_INTERVAL_MS) * Math.pow(2, event.row.num_retries),
|
|
133
164
|
)
|
|
165
|
+
const terminal = numRetries >= (event.target?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)
|
|
134
166
|
await UpdateBuilder.create()
|
|
135
167
|
.table('actions_event')
|
|
136
168
|
.values<EventRow>({
|
|
137
169
|
last_state_change: now,
|
|
138
170
|
num_retries: numRetries,
|
|
139
171
|
log: JSON.stringify([...event.row.log, event.result]) as any,
|
|
140
|
-
...(
|
|
172
|
+
...(terminal
|
|
141
173
|
? {
|
|
142
|
-
state: '
|
|
143
|
-
visible_at: nextAttempt,
|
|
174
|
+
state: 'failed',
|
|
144
175
|
}
|
|
145
176
|
: {
|
|
146
|
-
state: '
|
|
177
|
+
state: 'retrying',
|
|
178
|
+
visible_at: nextAttempt,
|
|
147
179
|
}),
|
|
148
180
|
})
|
|
149
181
|
.where({ id: event.row.id })
|
|
150
182
|
.execute(db)
|
|
183
|
+
return terminal
|
|
151
184
|
}
|
|
152
185
|
|
|
153
|
-
private async markFailedOnUnknownTarget(db: Client, targetName: string, primaryEventId: string) {
|
|
154
|
-
await UpdateBuilder.create()
|
|
186
|
+
private async markFailedOnUnknownTarget(db: Client, targetName: string, primaryEventId: string): Promise<boolean> {
|
|
187
|
+
const affectedRows = await UpdateBuilder.create()
|
|
155
188
|
.table('actions_event')
|
|
156
189
|
.where(it =>
|
|
157
190
|
it.or(it =>
|
|
158
191
|
it
|
|
159
192
|
.and(it =>
|
|
160
193
|
it
|
|
161
|
-
.in('state', ['
|
|
162
|
-
.compare('visible_at', Operator.lte, 'now')
|
|
194
|
+
.in('state', ['processing'])
|
|
163
195
|
.compare('target', Operator.eq, targetName)
|
|
164
196
|
)
|
|
165
197
|
.and(it =>
|
|
@@ -177,9 +209,19 @@ export class EventsRepository {
|
|
|
177
209
|
resolved_at: new Date(),
|
|
178
210
|
})
|
|
179
211
|
.execute(db)
|
|
212
|
+
return affectedRows > 0
|
|
180
213
|
}
|
|
181
214
|
}
|
|
182
215
|
|
|
183
216
|
export type FetchBatchResult =
|
|
184
|
-
| { ok: true; events: EventRow[]; target: Actions.AnyTarget }
|
|
185
|
-
| { ok: false; backoffMs?: number }
|
|
217
|
+
| { ok: true; events: EventRow[]; target: Actions.AnyTarget; unknownTargetFailed: number }
|
|
218
|
+
| { ok: false; backoffMs?: number; unknownTargetFailed: number }
|
|
219
|
+
|
|
220
|
+
export type PersistProcessedResult = {
|
|
221
|
+
/** Events delivered successfully (terminal). */
|
|
222
|
+
succeeded: number
|
|
223
|
+
/** Events whose delivery attempt failed and that will be retried later. */
|
|
224
|
+
retried: number
|
|
225
|
+
/** Events whose delivery attempt failed and that reached a terminal `failed` state. */
|
|
226
|
+
failed: number
|
|
227
|
+
}
|
|
@@ -4,17 +4,26 @@ import { ContentSchemaResolver } from '@contember/engine-http'
|
|
|
4
4
|
import { Runnable, RunnableArgs, Running } from '@contember/engine-common'
|
|
5
5
|
import { Listener } from '@contember/database'
|
|
6
6
|
import { NOTIFY_CHANNEL_NAME } from '../utils/notifyChannel.js'
|
|
7
|
+
import { ActionsMetrics, ProjectActionsMetrics } from '../ActionsMetrics.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Upper bound on how long the loop parks while idle. Even when the queue is empty (and we'd
|
|
11
|
+
* otherwise wait indefinitely on a `pg_notify`), we re-check at least this often, so a lost
|
|
12
|
+
* notification self-heals and the heartbeat keeps refreshing while the worker is genuinely idle.
|
|
13
|
+
*/
|
|
14
|
+
const MAX_IDLE_SLEEP_MS = 30_000
|
|
7
15
|
|
|
8
16
|
export class ProjectDispatcherFactory {
|
|
9
17
|
constructor(
|
|
10
18
|
private readonly dispatcher: EventDispatcher,
|
|
19
|
+
private readonly metrics: ActionsMetrics,
|
|
11
20
|
) {
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
public create(
|
|
15
24
|
{ db, contentSchemaResolver, projectSlug }: { db: DatabaseContext; contentSchemaResolver: ContentSchemaResolver; projectSlug: string },
|
|
16
25
|
): ProjectDispatcher {
|
|
17
|
-
return new ProjectDispatcher(this.dispatcher, db, contentSchemaResolver, projectSlug)
|
|
26
|
+
return new ProjectDispatcher(this.dispatcher, db, contentSchemaResolver, projectSlug, this.metrics.forProject(projectSlug))
|
|
18
27
|
}
|
|
19
28
|
}
|
|
20
29
|
|
|
@@ -24,6 +33,7 @@ export class ProjectDispatcher implements Runnable {
|
|
|
24
33
|
private readonly db: DatabaseContext,
|
|
25
34
|
private readonly contentSchemaResolver: ContentSchemaResolver,
|
|
26
35
|
private readonly projectSlug: string,
|
|
36
|
+
private readonly metrics: ProjectActionsMetrics,
|
|
27
37
|
) {
|
|
28
38
|
}
|
|
29
39
|
|
|
@@ -58,6 +68,7 @@ export class ProjectDispatcher implements Runnable {
|
|
|
58
68
|
end: async () => {
|
|
59
69
|
aborted = true
|
|
60
70
|
await listener.end()
|
|
71
|
+
this.metrics.dispose()
|
|
61
72
|
logger.info('Worker terminated', {
|
|
62
73
|
succeed: succeedTotal,
|
|
63
74
|
failed: failedTotal,
|
|
@@ -67,28 +78,32 @@ export class ProjectDispatcher implements Runnable {
|
|
|
67
78
|
|
|
68
79
|
try {
|
|
69
80
|
while (!aborted) {
|
|
70
|
-
|
|
81
|
+
this.metrics.heartbeat()
|
|
82
|
+
const { succeeded, retried, failedAfterAttempt, failedUnknownTarget, backoffMs } = await this.dispatcher.processBatch({
|
|
71
83
|
db,
|
|
72
84
|
contentSchemaResolver: this.contentSchemaResolver,
|
|
73
85
|
logger,
|
|
86
|
+
project: { slug: this.projectSlug },
|
|
74
87
|
})
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
const terminalFailed = failedAfterAttempt + failedUnknownTarget
|
|
89
|
+
this.metrics.succeeded(succeeded)
|
|
90
|
+
this.metrics.deliveryAttemptFailed(retried + failedAfterAttempt)
|
|
91
|
+
this.metrics.failed(terminalFailed)
|
|
92
|
+
succeedTotal += succeeded
|
|
93
|
+
failedTotal += terminalFailed
|
|
77
94
|
|
|
78
95
|
// listener error occurred during batch processing, rethrow
|
|
79
96
|
if (pendingError) {
|
|
80
97
|
throw pendingError
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
// queue is empty, wait
|
|
100
|
+
// queue is empty (or next retry is in the future), wait — but never longer than
|
|
101
|
+
// MAX_IDLE_SLEEP_MS, so a lost notification self-heals and the heartbeat stays fresh.
|
|
84
102
|
if (backoffMs !== 0) {
|
|
103
|
+
const waitMs = Math.min(backoffMs ?? MAX_IDLE_SLEEP_MS, MAX_IDLE_SLEEP_MS)
|
|
85
104
|
await new Promise<void>((resolve, reject) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (backoffMs !== undefined) {
|
|
89
|
-
const timeoutHandle = setTimeout(resolve, backoffMs)
|
|
90
|
-
cleanup = () => clearTimeout(timeoutHandle)
|
|
91
|
-
}
|
|
105
|
+
const timeoutHandle = setTimeout(resolve, waitMs)
|
|
106
|
+
const cleanup = () => clearTimeout(timeoutHandle)
|
|
92
107
|
resolvePending = () => {
|
|
93
108
|
resolve()
|
|
94
109
|
cleanup()
|
|
@@ -103,6 +118,7 @@ export class ProjectDispatcher implements Runnable {
|
|
|
103
118
|
onClose?.()
|
|
104
119
|
} catch (e) {
|
|
105
120
|
await listener.end()
|
|
121
|
+
this.metrics.crashed()
|
|
106
122
|
onError(e)
|
|
107
123
|
}
|
|
108
124
|
})
|
|
@@ -3,6 +3,7 @@ import { EventRow, HandledEvent, InvokeHandler, InvokeHandlerArgs } from './type
|
|
|
3
3
|
import { VariablesMap } from '../model/VariablesManager.js'
|
|
4
4
|
import * as Typesafe from '@contember/typesafe'
|
|
5
5
|
import { FetcherResponse, WebhookFetcher } from './WebhookFetcher.js'
|
|
6
|
+
import { Logger } from '@contember/logger'
|
|
6
7
|
|
|
7
8
|
const DEFAULT_TIMEOUT_MS = 30_000 // 30 seconds
|
|
8
9
|
|
|
@@ -40,6 +41,8 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
|
|
|
40
41
|
eventResponseFactory = this.createResponseFactory({
|
|
41
42
|
response: response,
|
|
42
43
|
events,
|
|
44
|
+
target,
|
|
45
|
+
logger,
|
|
43
46
|
})
|
|
44
47
|
} catch (e) {
|
|
45
48
|
logger.warn(e)
|
|
@@ -58,11 +61,20 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
|
|
|
58
61
|
return events.map(it => ({ target, row: it, result: { durationMs, ...eventResponseFactory(it) } }))
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
private createResponseFactory({ response, events }: {
|
|
64
|
+
private createResponseFactory({ response, events, target, logger }: {
|
|
62
65
|
response: FetcherResponse
|
|
63
66
|
events: EventRow[]
|
|
67
|
+
target: Actions.WebhookTarget
|
|
68
|
+
logger: Logger
|
|
64
69
|
}): EventResponseFactory {
|
|
65
70
|
if (!response.ok) {
|
|
71
|
+
// The target is identified by name only: its URL and headers may carry credentials.
|
|
72
|
+
logger.warn('Webhook target responded with an error status', {
|
|
73
|
+
target: target.name,
|
|
74
|
+
status: response.status,
|
|
75
|
+
statusText: response.statusText,
|
|
76
|
+
events: events.map(it => it.id),
|
|
77
|
+
})
|
|
66
78
|
return () => ({
|
|
67
79
|
ok: false,
|
|
68
80
|
code: response.status,
|
|
@@ -95,6 +107,13 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
|
|
|
95
107
|
const missingEvents = Object.keys(failedEvents).filter(it => !eventsInBatch.has(it))
|
|
96
108
|
|
|
97
109
|
if (missingEvents.length) {
|
|
110
|
+
// Only the count of unknown ids: they are strings the target chose, not ours.
|
|
111
|
+
logger.warn('Webhook target reported failures for event ids outside the batch', {
|
|
112
|
+
target: target.name,
|
|
113
|
+
status: response.status,
|
|
114
|
+
unknownEventIds: missingEvents.length,
|
|
115
|
+
events: events.map(it => it.id),
|
|
116
|
+
})
|
|
98
117
|
return () => ({
|
|
99
118
|
ok: false,
|
|
100
119
|
code: response.status,
|
|
@@ -103,6 +122,16 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
|
|
|
103
122
|
})
|
|
104
123
|
}
|
|
105
124
|
|
|
125
|
+
const failedIds = events.filter(it => failedEvents[it.id]).map(it => it.id)
|
|
126
|
+
if (failedIds.length) {
|
|
127
|
+
// Per-event errors come from the target's payload and stay in the event log, not here.
|
|
128
|
+
logger.warn('Webhook target reported failed events', {
|
|
129
|
+
target: target.name,
|
|
130
|
+
status: response.status,
|
|
131
|
+
events: failedIds,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
106
135
|
return it => {
|
|
107
136
|
if (!failedEvents[it.id]) {
|
|
108
137
|
return { ok: true, code: response.status }
|
|
@@ -114,6 +143,12 @@ export class WebhookTargetHandler implements InvokeHandler<Actions.WebhookTarget
|
|
|
114
143
|
}
|
|
115
144
|
}
|
|
116
145
|
} catch (e: any) {
|
|
146
|
+
// The parser message quotes the offending payload, so it stays in the event log only.
|
|
147
|
+
logger.warn('Webhook target returned a malformed response', {
|
|
148
|
+
target: target.name,
|
|
149
|
+
status: response.status,
|
|
150
|
+
events: events.map(it => it.id),
|
|
151
|
+
})
|
|
117
152
|
return () => ({
|
|
118
153
|
ok: false,
|
|
119
154
|
code: response.status,
|