@contember/engine-actions 2.1.0-beta.1 → 2.1.0-beta.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.
Files changed (49) hide show
  1. package/dist/development/index.cjs +10 -4
  2. package/dist/development/index.cjs.map +1 -1
  3. package/dist/development/index.js +10 -4
  4. package/dist/development/index.js.map +1 -1
  5. package/dist/development/src/ActionsExecutionContainerHookFactory.cjs +3 -2
  6. package/dist/development/src/ActionsExecutionContainerHookFactory.cjs.map +1 -1
  7. package/dist/development/src/ActionsExecutionContainerHookFactory.js +3 -2
  8. package/dist/development/src/ActionsExecutionContainerHookFactory.js.map +1 -1
  9. package/dist/development/src/dispatch/LazyDispatchWorker.cjs +67 -0
  10. package/dist/development/src/dispatch/LazyDispatchWorker.cjs.map +1 -0
  11. package/dist/development/src/dispatch/LazyDispatchWorker.js +67 -0
  12. package/dist/development/src/dispatch/LazyDispatchWorker.js.map +1 -0
  13. package/dist/development/src/triggers/TriggerPayloadPersister.cjs +32 -20
  14. package/dist/development/src/triggers/TriggerPayloadPersister.cjs.map +1 -1
  15. package/dist/development/src/triggers/TriggerPayloadPersister.js +32 -20
  16. package/dist/development/src/triggers/TriggerPayloadPersister.js.map +1 -1
  17. package/dist/production/index.cjs +10 -4
  18. package/dist/production/index.cjs.map +1 -1
  19. package/dist/production/index.js +10 -4
  20. package/dist/production/index.js.map +1 -1
  21. package/dist/production/src/ActionsExecutionContainerHookFactory.cjs +3 -2
  22. package/dist/production/src/ActionsExecutionContainerHookFactory.cjs.map +1 -1
  23. package/dist/production/src/ActionsExecutionContainerHookFactory.js +3 -2
  24. package/dist/production/src/ActionsExecutionContainerHookFactory.js.map +1 -1
  25. package/dist/production/src/dispatch/LazyDispatchWorker.cjs +67 -0
  26. package/dist/production/src/dispatch/LazyDispatchWorker.cjs.map +1 -0
  27. package/dist/production/src/dispatch/LazyDispatchWorker.js +67 -0
  28. package/dist/production/src/dispatch/LazyDispatchWorker.js.map +1 -0
  29. package/dist/production/src/triggers/TriggerPayloadPersister.cjs +32 -20
  30. package/dist/production/src/triggers/TriggerPayloadPersister.cjs.map +1 -1
  31. package/dist/production/src/triggers/TriggerPayloadPersister.js +32 -20
  32. package/dist/production/src/triggers/TriggerPayloadPersister.js.map +1 -1
  33. package/dist/tests/cases/unit/lazyDispatchWorker.test.d.ts +2 -0
  34. package/dist/tests/cases/unit/lazyDispatchWorker.test.d.ts.map +1 -0
  35. package/dist/tests/tsconfig.tsbuildinfo +1 -1
  36. package/dist/types/ActionsExecutionContainerHookFactory.d.ts.map +1 -1
  37. package/dist/types/dispatch/LazyDispatchWorker.d.ts +11 -0
  38. package/dist/types/dispatch/LazyDispatchWorker.d.ts.map +1 -0
  39. package/dist/types/index.d.ts.map +1 -1
  40. package/dist/types/triggers/TriggerPayloadPersister.d.ts +3 -2
  41. package/dist/types/triggers/TriggerPayloadPersister.d.ts.map +1 -1
  42. package/dist/types/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/src/ActionsExecutionContainerHookFactory.ts +5 -1
  45. package/src/dispatch/LazyDispatchWorker.ts +72 -0
  46. package/src/index.ts +11 -5
  47. package/src/triggers/TriggerPayloadPersister.ts +20 -7
  48. package/tests/cases/unit/lazyDispatchWorker.test.ts +226 -0
  49. package/vitest.config.ts +0 -3
package/package.json CHANGED
@@ -49,7 +49,7 @@
49
49
  "pg": "^8.20.0"
50
50
  },
51
51
  "type": "module",
52
- "version": "2.1.0-beta.1",
52
+ "version": "2.1.0-beta.2",
53
53
  "repository": {
54
54
  "type": "git",
55
55
  "url": "git+https://github.com/contember/contember.git",
@@ -14,7 +14,10 @@ export class ActionsExecutionContainerHookFactory {
14
14
  return builder => {
15
15
  return builder.setupService(
16
16
  'mapperFactory',
17
- (mapperFactory, { whereBuilder, schema, pathFactory, systemSchema, providers, stage, project, schemaMeta, joinBuilder, userInfo }) => {
17
+ (
18
+ mapperFactory,
19
+ { whereBuilder, schema, pathFactory, systemSchema, providers, stage, project, schemaMeta, joinBuilder, userInfo, triggeredActionsCollector },
20
+ ) => {
18
21
  mapperFactory.hooks.push(mapper => {
19
22
  const triggerPayloadPersister = new TriggerPayloadPersister(
20
23
  mapper,
@@ -25,6 +28,7 @@ export class ActionsExecutionContainerHookFactory {
25
28
  schemaMeta.id,
26
29
  mapper.identityId,
27
30
  userInfo,
31
+ triggeredActionsCollector,
28
32
  )
29
33
  const triggerPayloadBuilder = new TriggerPayloadBuilder(mapper)
30
34
  const payloadManager = new TriggerPayloadManager(triggerPayloadBuilder, triggerPayloadPersister)
@@ -0,0 +1,72 @@
1
+ import { Runnable, RunnableArgs, Running, Supervisor, SupervisorOptions } from '@contember/engine-common'
2
+ import { ProjectGroupContainerResolver } from '@contember/engine-http'
3
+ import { DispatchWorkerSupervisorFactory } from './DispatchWorkerSupervisor'
4
+
5
+ export class LazyDispatchWorker implements Runnable {
6
+ constructor(
7
+ private readonly projectGroupContainerResolver: ProjectGroupContainerResolver,
8
+ private readonly supervisorFactory: DispatchWorkerSupervisorFactory,
9
+ private readonly supervisorOptions: SupervisorOptions = { startupMax: 10 },
10
+ ) {
11
+ }
12
+
13
+ public async run({ logger, onError }: RunnableArgs): Promise<Running> {
14
+ const running = new Map<string | undefined, Promise<Running | undefined>>()
15
+ let aborted = false
16
+
17
+ const unlisten = this.projectGroupContainerResolver.on('create', ({ container, slug }) => {
18
+ const groupLabel = slug ?? '<default>'
19
+ const childLogger = logger.child({ projectGroup: groupLabel })
20
+ childLogger.debug(`Starting dispatch worker for project group ${groupLabel}`)
21
+
22
+ const supervised = new Supervisor(
23
+ this.supervisorFactory.create(container),
24
+ this.supervisorOptions,
25
+ )
26
+ const runningPromise: Promise<Running | undefined> = supervised.run({
27
+ logger: childLogger,
28
+ onError: e => {
29
+ if (aborted) {
30
+ return
31
+ }
32
+ childLogger.error(e, { message: `Dispatch worker for project group ${groupLabel} crashed` })
33
+ onError(e)
34
+ },
35
+ }).catch(e => {
36
+ if (!aborted) {
37
+ childLogger.error(e, { message: `Failed to start dispatch worker for project group ${groupLabel}` })
38
+ onError(e)
39
+ }
40
+ return undefined
41
+ })
42
+ running.set(slug, runningPromise)
43
+
44
+ return () => {
45
+ running.delete(slug)
46
+ ;(async () => {
47
+ try {
48
+ const r = await runningPromise
49
+ await r?.end()
50
+ } catch {
51
+ }
52
+ })()
53
+ }
54
+ })
55
+
56
+ return {
57
+ end: async () => {
58
+ aborted = true
59
+ unlisten()
60
+ const all = [...running.values()]
61
+ running.clear()
62
+ await Promise.allSettled(all.map(async p => {
63
+ try {
64
+ const r = await p
65
+ await r?.end()
66
+ } catch {
67
+ }
68
+ }))
69
+ },
70
+ }
71
+ }
72
+ }
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import { WebhookTargetHandler } from './dispatch/WebhookTargetHandler'
21
21
  import { ActionsContextResolver } from './graphql/http/ActionsContextResolver'
22
22
  import { ActionsWebsocketControllerFactory } from './graphql/http/ActionsWebsocketControllerFactory'
23
23
  import { DispatchWorkerSupervisorFactory } from './dispatch/DispatchWorkerSupervisor'
24
+ import { LazyDispatchWorker } from './dispatch/LazyDispatchWorker'
24
25
  import { ProjectDispatcherFactory } from './dispatch/ProjectDispatcher'
25
26
  import { VariablesQueryResolver } from './graphql/resolvers/query/VariablesQueryResolver'
26
27
  import { VariablesManager } from './model/VariablesManager'
@@ -108,11 +109,16 @@ export default class ActionsPlugin implements Plugin {
108
109
  it.addWebsocketRoute('actions', '/actions/_worker', actionsWebsocketMiddlewareFactory.create())
109
110
  },
110
111
  )
111
- .setupService('applicationWorkers', (it, { actions_dispatchWorkerSupervisorFactory, projectGroupContainer, serverConfig }) => {
112
- if (!(serverConfig as any).projectGroup) {
113
- it.registerWorker(this.name, actions_dispatchWorkerSupervisorFactory.create(projectGroupContainer))
114
- }
115
- }) as unknown as MasterContainerBuilder
112
+ .setupService(
113
+ 'applicationWorkers',
114
+ (it, { actions_dispatchWorkerSupervisorFactory, projectGroupContainer, projectGroupContainerResolver, serverConfig }) => {
115
+ if (!(serverConfig as any).projectGroup) {
116
+ it.registerWorker(this.name, actions_dispatchWorkerSupervisorFactory.create(projectGroupContainer))
117
+ } else {
118
+ it.registerWorker(this.name, new LazyDispatchWorker(projectGroupContainerResolver, actions_dispatchWorkerSupervisorFactory))
119
+ }
120
+ },
121
+ ) as unknown as MasterContainerBuilder
116
122
  }
117
123
  return hook
118
124
  }
@@ -1,5 +1,5 @@
1
1
  import { Client, InsertBuilder } from '@contember/database'
2
- import { Mapper } from '@contember/engine-content-api'
2
+ import { Mapper, TriggeredActionEvent, TriggeredActionsCollector } from '@contember/engine-content-api'
3
3
  import { Actions, ActionsPayload } from '@contember/schema'
4
4
  import { EventRow } from '../model/types'
5
5
  import { notify } from '../utils/notifyChannel'
@@ -20,6 +20,7 @@ export class TriggerPayloadPersister {
20
20
  private readonly schemaId: number | undefined,
21
21
  private readonly identityId: string,
22
22
  private readonly userInfo: { ipAddress: string | null; userAgent: string | null },
23
+ private readonly triggeredActionsCollector: TriggeredActionsCollector | undefined,
23
24
  ) {
24
25
  }
25
26
 
@@ -28,13 +29,17 @@ export class TriggerPayloadPersister {
28
29
  if (!this.schemaId) {
29
30
  throw new Error('Schema id is not set')
30
31
  }
32
+ const collector = this.triggeredActionsCollector
31
33
  for (let i = 0; i < payloads.length; i += chunkSize) {
32
34
  const chunk = payloads.slice(i, i + chunkSize)
33
- await InsertBuilder.create()
34
- .into('actions_event')
35
- .values(chunk.map((it): EventRowToInsert => ({
36
- id: this.providers.uuid(),
37
- transaction_id: this.mapper.transactionId,
35
+ const collected: TriggeredActionEvent[] | undefined = collector ? [] : undefined
36
+ const rows = chunk.map((it): EventRowToInsert => {
37
+ const id = this.providers.uuid()
38
+ const transactionId = this.mapper.transactionId
39
+ collected?.push({ id, trigger: trigger.name, target: trigger.target, transactionId })
40
+ return {
41
+ id,
42
+ transaction_id: transactionId,
38
43
  created_at: 'now',
39
44
  visible_at: 'now',
40
45
  num_retries: 0,
@@ -50,8 +55,16 @@ export class TriggerPayloadPersister {
50
55
  identity_id: this.identityId,
51
56
  ip_address: this.userInfo.ipAddress,
52
57
  user_agent: this.userInfo.userAgent,
53
- })))
58
+ }
59
+ })
60
+ await InsertBuilder.create()
61
+ .into('actions_event')
62
+ .values(rows)
54
63
  .execute(this.client)
64
+
65
+ if (collected && collected.length > 0) {
66
+ collector!.add(collected)
67
+ }
55
68
  }
56
69
 
57
70
  await notify(this.client, this.projectSlug)
@@ -0,0 +1,226 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { Runnable, RunnableArgs, Running } from '@contember/engine-common'
3
+ import { createLogger, TestLoggerHandler } from '@contember/logger'
4
+ import { LazyDispatchWorker } from '../../../src/dispatch/LazyDispatchWorker'
5
+ import { DispatchWorkerSupervisorFactory } from '../../../src/dispatch/DispatchWorkerSupervisor'
6
+ import { ProjectGroupContainerResolver } from '@contember/engine-http'
7
+ import { ProjectGroupContainer } from '@contember/engine-http'
8
+
9
+ const silentLogger = createLogger(new TestLoggerHandler())
10
+
11
+ const tick = () => new Promise<void>(r => setImmediate(r))
12
+
13
+ const waitFor = async (cond: () => boolean, attempts = 50) => {
14
+ for (let i = 0; i < attempts; i++) {
15
+ if (cond()) return
16
+ await tick()
17
+ }
18
+ throw new Error('waitFor: condition not met')
19
+ }
20
+
21
+ type FakeRunnable = Runnable & {
22
+ id: string
23
+ startCount: number
24
+ endCount: number
25
+ failOnRun?: () => Error | undefined
26
+ emitError?: (e: any) => void
27
+ }
28
+
29
+ const createFakeSupervisor = (id: string, opts: { failOnRun?: () => Error | undefined } = {}): FakeRunnable => {
30
+ const fake: FakeRunnable = {
31
+ id,
32
+ startCount: 0,
33
+ endCount: 0,
34
+ failOnRun: opts.failOnRun,
35
+ async run(args: RunnableArgs): Promise<Running> {
36
+ fake.startCount++
37
+ const failure = fake.failOnRun?.()
38
+ if (failure) {
39
+ throw failure
40
+ }
41
+ fake.emitError = args.onError
42
+ return {
43
+ end: async () => {
44
+ fake.endCount++
45
+ },
46
+ }
47
+ },
48
+ }
49
+ return fake
50
+ }
51
+
52
+ type FakeResolver = Pick<ProjectGroupContainerResolver, 'on'> & {
53
+ fire: (slug: string | undefined) => (() => void) | undefined
54
+ listenerCount: () => number
55
+ }
56
+
57
+ const createFakeResolver = (): FakeResolver => {
58
+ const listeners: Array<(args: { container: ProjectGroupContainer; slug: string | undefined }) => void | (() => void)> = []
59
+ return {
60
+ on(_event, cb) {
61
+ listeners.push(cb as any)
62
+ return () => {
63
+ const idx = listeners.indexOf(cb as any)
64
+ if (idx >= 0) listeners.splice(idx, 1)
65
+ }
66
+ },
67
+ fire(slug) {
68
+ const fakeContainer = { slug } as unknown as ProjectGroupContainer
69
+ const cleanups = listeners.map(it => it({ container: fakeContainer, slug }))
70
+ return () => cleanups.forEach(it => it?.())
71
+ },
72
+ listenerCount: () => listeners.length,
73
+ }
74
+ }
75
+
76
+ const createFakeFactory = (registry: FakeRunnable[]): DispatchWorkerSupervisorFactory => {
77
+ let counter = 0
78
+ return {
79
+ create(_container: ProjectGroupContainer): any {
80
+ const fake = createFakeSupervisor(`fake-${counter++}`)
81
+ registry.push(fake)
82
+ return fake
83
+ },
84
+ } as any
85
+ }
86
+
87
+ const recordOnError = () => {
88
+ const errors: any[] = []
89
+ return { errors, onError: (e: any) => errors.push(e) }
90
+ }
91
+
92
+ describe('LazyDispatchWorker', () => {
93
+ test('does not spawn supervisor before any project group is created', async () => {
94
+ const resolver = createFakeResolver()
95
+ const supervisors: FakeRunnable[] = []
96
+ const worker = new LazyDispatchWorker(resolver as any, createFakeFactory(supervisors))
97
+ const { onError } = recordOnError()
98
+
99
+ const running = await worker.run({ logger: silentLogger, onError })
100
+
101
+ expect(supervisors).toHaveLength(0)
102
+ expect(resolver.listenerCount()).toBe(1)
103
+
104
+ await running.end()
105
+ expect(resolver.listenerCount()).toBe(0)
106
+ })
107
+
108
+ test('spawns supervisor when project group is created', async () => {
109
+ const resolver = createFakeResolver()
110
+ const supervisors: FakeRunnable[] = []
111
+ const worker = new LazyDispatchWorker(resolver as any, createFakeFactory(supervisors))
112
+ const { onError, errors } = recordOnError()
113
+
114
+ const running = await worker.run({ logger: silentLogger, onError })
115
+
116
+ resolver.fire('tenantA')
117
+ await waitFor(() => supervisors.length === 1 && supervisors[0].startCount === 1)
118
+
119
+ expect(supervisors[0].startCount).toBe(1)
120
+ expect(supervisors[0].endCount).toBe(0)
121
+ expect(errors).toHaveLength(0)
122
+
123
+ await running.end()
124
+ expect(supervisors[0].endCount).toBe(1)
125
+ })
126
+
127
+ test('spawns one supervisor per project group, isolated', async () => {
128
+ const resolver = createFakeResolver()
129
+ const supervisors: FakeRunnable[] = []
130
+ const worker = new LazyDispatchWorker(resolver as any, createFakeFactory(supervisors))
131
+ const { onError } = recordOnError()
132
+
133
+ const running = await worker.run({ logger: silentLogger, onError })
134
+
135
+ resolver.fire('tenantA')
136
+ resolver.fire('tenantB')
137
+ await waitFor(() => supervisors.length === 2 && supervisors.every(it => it.startCount === 1))
138
+
139
+ await running.end()
140
+ expect(supervisors.map(it => it.endCount)).toEqual([1, 1])
141
+ })
142
+
143
+ test('container invalidation ends only that supervisor', async () => {
144
+ const resolver = createFakeResolver()
145
+ const supervisors: FakeRunnable[] = []
146
+ const worker = new LazyDispatchWorker(resolver as any, createFakeFactory(supervisors))
147
+ const { onError } = recordOnError()
148
+
149
+ const running = await worker.run({ logger: silentLogger, onError })
150
+
151
+ const cleanupA = resolver.fire('tenantA')!
152
+ resolver.fire('tenantB')
153
+ await waitFor(() => supervisors.length === 2 && supervisors.every(it => it.startCount === 1))
154
+
155
+ cleanupA()
156
+ await waitFor(() => supervisors[0].endCount === 1)
157
+
158
+ expect(supervisors[0].endCount).toBe(1)
159
+ expect(supervisors[1].endCount).toBe(0)
160
+
161
+ await running.end()
162
+ expect(supervisors[1].endCount).toBe(1)
163
+ })
164
+
165
+ test('end() unsubscribes listener, no further supervisors are spawned', async () => {
166
+ const resolver = createFakeResolver()
167
+ const supervisors: FakeRunnable[] = []
168
+ const worker = new LazyDispatchWorker(resolver as any, createFakeFactory(supervisors))
169
+ const { onError } = recordOnError()
170
+
171
+ const running = await worker.run({ logger: silentLogger, onError })
172
+ expect(resolver.listenerCount()).toBe(1)
173
+
174
+ await running.end()
175
+ expect(resolver.listenerCount()).toBe(0)
176
+
177
+ resolver.fire('tenantLate')
178
+ await tick()
179
+ expect(supervisors).toHaveLength(0)
180
+ })
181
+
182
+ test('startup failure of a tenant supervisor propagates to onError once retries are exhausted', async () => {
183
+ const resolver = createFakeResolver()
184
+ const registry: FakeRunnable[] = []
185
+ const factory: DispatchWorkerSupervisorFactory = {
186
+ create(_container: ProjectGroupContainer): any {
187
+ const fake = createFakeSupervisor(`fake-${registry.length}`, {
188
+ failOnRun: () => new Error('boom'),
189
+ })
190
+ registry.push(fake)
191
+ return fake
192
+ },
193
+ } as any
194
+ const worker = new LazyDispatchWorker(resolver as any, factory, { startupMax: 0, minBackoff: 1 })
195
+ const { onError, errors } = recordOnError()
196
+
197
+ const running = await worker.run({ logger: silentLogger, onError })
198
+
199
+ resolver.fire('tenantBroken')
200
+ await waitFor(() => errors.length > 0, 200)
201
+
202
+ expect(errors[0]).toBeInstanceOf(Error)
203
+ expect((errors[0] as Error).message).toContain('boom')
204
+
205
+ await running.end()
206
+ })
207
+
208
+ test('errors after end() are swallowed', async () => {
209
+ const resolver = createFakeResolver()
210
+ const supervisors: FakeRunnable[] = []
211
+ const worker = new LazyDispatchWorker(resolver as any, createFakeFactory(supervisors))
212
+ const { onError, errors } = recordOnError()
213
+
214
+ const running = await worker.run({ logger: silentLogger, onError })
215
+
216
+ resolver.fire('tenantA')
217
+ await waitFor(() => supervisors.length === 1 && supervisors[0].emitError !== undefined)
218
+
219
+ await running.end()
220
+ expect(supervisors[0].endCount).toBe(1)
221
+
222
+ supervisors[0].emitError!(new Error('after end'))
223
+ await tick()
224
+ expect(errors).toHaveLength(0)
225
+ })
226
+ })
package/vitest.config.ts DELETED
@@ -1,3 +0,0 @@
1
- import { defineProject } from 'vitest/config'
2
-
3
- export default defineProject({})