@contember/engine-actions 2.2.0-alpha.4 → 2.2.0-alpha.6

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 (27) hide show
  1. package/dist/development/index.cjs +0 -1
  2. package/dist/development/index.cjs.map +1 -1
  3. package/dist/development/index.js +0 -1
  4. package/dist/development/index.js.map +1 -1
  5. package/dist/development/src/graphql/http/ActionsWebsocketControllerFactory.cjs +68 -28
  6. package/dist/development/src/graphql/http/ActionsWebsocketControllerFactory.cjs.map +1 -1
  7. package/dist/development/src/graphql/http/ActionsWebsocketControllerFactory.js +68 -28
  8. package/dist/development/src/graphql/http/ActionsWebsocketControllerFactory.js.map +1 -1
  9. package/dist/production/index.cjs +0 -1
  10. package/dist/production/index.cjs.map +1 -1
  11. package/dist/production/index.js +0 -1
  12. package/dist/production/index.js.map +1 -1
  13. package/dist/production/src/graphql/http/ActionsWebsocketControllerFactory.cjs +68 -28
  14. package/dist/production/src/graphql/http/ActionsWebsocketControllerFactory.cjs.map +1 -1
  15. package/dist/production/src/graphql/http/ActionsWebsocketControllerFactory.js +68 -28
  16. package/dist/production/src/graphql/http/ActionsWebsocketControllerFactory.js.map +1 -1
  17. package/dist/tests/cases/unit/actionsWebsocketController.test.d.ts +2 -0
  18. package/dist/tests/cases/unit/actionsWebsocketController.test.d.ts.map +1 -0
  19. package/dist/tests/tsconfig.tsbuildinfo +1 -1
  20. package/dist/types/graphql/http/ActionsWebsocketControllerFactory.d.ts +7 -5
  21. package/dist/types/graphql/http/ActionsWebsocketControllerFactory.d.ts.map +1 -1
  22. package/dist/types/index.d.ts.map +1 -1
  23. package/dist/types/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +16 -16
  25. package/src/graphql/http/ActionsWebsocketControllerFactory.ts +73 -30
  26. package/src/index.ts +0 -1
  27. package/tests/cases/unit/actionsWebsocketController.test.ts +270 -0
package/package.json CHANGED
@@ -22,26 +22,26 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
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",
25
+ "@contember/authorization": "2.2.0-alpha.6",
26
+ "@contember/database": "2.2.0-alpha.6",
27
+ "@contember/database-migrations": "2.2.0-alpha.6",
28
+ "@contember/engine-common": "2.2.0-alpha.6",
29
+ "@contember/engine-content-api": "2.2.0-alpha.6",
30
+ "@contember/engine-http": "2.2.0-alpha.6",
31
+ "@contember/engine-plugins": "2.2.0-alpha.6",
32
+ "@contember/engine-system-api": "2.2.0-alpha.6",
33
+ "@contember/engine-tenant-api": "2.2.0-alpha.6",
34
+ "@contember/graphql-utils": "2.2.0-alpha.6",
35
+ "@contember/logger": "2.2.0-alpha.6",
36
+ "@contember/schema": "2.2.0-alpha.6",
37
+ "@contember/schema-utils": "2.2.0-alpha.6",
38
+ "@contember/typesafe": "2.2.0-alpha.6",
39
39
  "@graphql-tools/schema": "^10.0.31",
40
40
  "graphql-tag": "^2.12.6",
41
41
  "prom-client": "^12.0.0"
42
42
  },
43
43
  "devDependencies": {
44
- "@contember/database-tester": "2.2.0-alpha.4",
44
+ "@contember/database-tester": "2.2.0-alpha.6",
45
45
  "@types/bun": "^1.3.10",
46
46
  "@types/ws": "^8.18.1",
47
47
  "graphql": "^16.13.1"
@@ -51,7 +51,7 @@
51
51
  "pg": "^8.20.0"
52
52
  },
53
53
  "type": "module",
54
- "version": "2.2.0-alpha.4",
54
+ "version": "2.2.0-alpha.6",
55
55
  "repository": {
56
56
  "type": "git",
57
57
  "url": "git+https://github.com/contember/contember.git",
@@ -1,9 +1,7 @@
1
- import { HttpErrorResponse, WebSocketController } from '@contember/engine-http'
2
- import { ActionsContextResolver } from './ActionsContextResolver.js'
3
- import { DispatchWorkerSupervisorFactory } from '../../dispatch/DispatchWorkerSupervisor.js'
1
+ import { HttpErrorResponse, ProjectGroupContainer, WebSocketController } from '@contember/engine-http'
4
2
  import * as Typesafe from '@contember/typesafe'
5
3
  import { TenantRole } from '@contember/engine-tenant-api'
6
- import { Running } from '@contember/engine-common'
4
+ import { Runnable, Running } from '@contember/engine-common'
7
5
 
8
6
  const IncomingMessage = Typesafe.discriminatedUnion(
9
7
  'type',
@@ -41,11 +39,14 @@ const OutgoingMessage = Typesafe.discriminatedUnion(
41
39
 
42
40
  type OutgoingMessage = ReturnType<typeof OutgoingMessage>
43
41
 
42
+ type DispatchWorkerFactory = {
43
+ create(projectGroup: ProjectGroupContainer): Runnable
44
+ }
45
+
44
46
  export class ActionsWebsocketControllerFactory {
45
47
  constructor(
46
48
  private readonly debug: boolean,
47
- private readonly actionsContextResolver: ActionsContextResolver,
48
- private readonly dispatchWorkerSupervisorFactory: DispatchWorkerSupervisorFactory,
49
+ private readonly dispatchWorkerSupervisorFactory: DispatchWorkerFactory,
49
50
  ) {
50
51
  }
51
52
 
@@ -67,24 +68,44 @@ export class ActionsWebsocketControllerFactory {
67
68
  }
68
69
 
69
70
  let workers: { id: string; running: Promise<Running> }[] = []
71
+ let stopAllPromise: Promise<void> | undefined
72
+ let connectionClosing = false
70
73
  const abortListener = async () => {
71
- send({ type: 'message', message: 'shutting down' })
72
- await stopAll()
73
- send({ type: 'message', message: 'closing connection' })
74
- ws.close(1012) // Service Restart
74
+ connectionClosing = true
75
+ try {
76
+ send({ type: 'message', message: 'shutting down' })
77
+ await stopAll()
78
+ } catch (error) {
79
+ logger.error(error, { message: 'Websocket worker cleanup failed' })
80
+ } finally {
81
+ try {
82
+ send({ type: 'message', message: 'closing connection' })
83
+ } finally {
84
+ ws.close(1012) // Service Restart
85
+ }
86
+ }
75
87
  }
76
88
  ctx.abortSignal.addEventListener('abort', abortListener)
77
89
 
78
- const stopAll = async () => {
79
- const currentWorkers = workers.map(async it => {
80
- await (await it.running).end()
81
- send({
82
- type: 'workedStopped',
83
- workerId: it.id,
84
- })
90
+ const stopAll = (): Promise<void> => {
91
+ stopAllPromise ??= (async () => {
92
+ // a worker stays tracked until its own end() resolves, so a failed cleanup is not forgotten
93
+ const results = await Promise.allSettled(workers.map(async it => {
94
+ await (await it.running).end()
95
+ workers = workers.filter(worker => worker !== it)
96
+ send({
97
+ type: 'workedStopped',
98
+ workerId: it.id,
99
+ })
100
+ }))
101
+ const errors = results.flatMap(it => it.status === 'rejected' ? [it.reason] : [])
102
+ if (errors.length > 0) {
103
+ throw new AggregateError(errors, 'Worker cleanup failed')
104
+ }
105
+ })().finally(() => {
106
+ stopAllPromise = undefined
85
107
  })
86
- workers = []
87
- await Promise.all(currentWorkers)
108
+ return stopAllPromise
88
109
  }
89
110
  send({ type: 'ready' })
90
111
 
@@ -110,27 +131,41 @@ export class ActionsWebsocketControllerFactory {
110
131
 
111
132
  switch (data.type) {
112
133
  case 'startWorker':
134
+ if (connectionClosing || stopAllPromise !== undefined) {
135
+ send({ type: 'error', message: 'Workers are stopping' })
136
+ break
137
+ }
113
138
  const workerId = Math.random().toString().substring(2)
114
139
  const dispatchSupervisor = this.dispatchWorkerSupervisorFactory.create(projectGroup)
115
- try {
116
- const running = dispatchSupervisor.run({
140
+ const running = Promise.resolve().then(() =>
141
+ dispatchSupervisor.run({
117
142
  logger,
118
143
  onError: () => {
119
144
  send({ type: 'workerCrashed', workerId })
120
145
  },
121
146
  })
122
- workers.push({ id: workerId, running })
147
+ )
148
+ const worker = { id: workerId, running }
149
+ workers.push(worker)
150
+ try {
123
151
  await running
124
- send({ type: 'workerStarted', workerId })
125
152
  } catch (e) {
153
+ workers = workers.filter(it => it !== worker)
126
154
  logger.error(e, { message: 'Worker failed to start' })
127
155
  send({ type: 'workerFailedToStart', workerId })
156
+ break
128
157
  }
158
+ send({ type: 'workerStarted', workerId })
129
159
  break
130
160
  case 'stopAllWorkers':
131
161
  send({ type: 'message', message: 'stopping' })
132
- await stopAll()
133
- send({ type: 'message', message: 'all stopped' })
162
+ try {
163
+ await stopAll()
164
+ send({ type: 'message', message: 'all stopped' })
165
+ } catch (error) {
166
+ logger.error(error, { message: 'Websocket worker cleanup failed' })
167
+ send({ type: 'error', message: 'Worker cleanup failed' })
168
+ }
134
169
  break
135
170
  }
136
171
  })
@@ -139,11 +174,19 @@ export class ActionsWebsocketControllerFactory {
139
174
  ws.ping()
140
175
  }, 5000)
141
176
 
142
- ws.addEventListener('close', async () => {
143
- ctx.abortSignal.removeEventListener('abort', abortListener)
144
- clearInterval(pingHandle)
145
- await stopAll()
146
- })
177
+ ctx.waitUntil(
178
+ new Promise<void>(resolve => {
179
+ ws.addEventListener('close', () => {
180
+ connectionClosing = true
181
+ ctx.abortSignal.removeEventListener('abort', abortListener)
182
+ clearInterval(pingHandle)
183
+ void stopAll().then(resolve, error => {
184
+ logger.error(error, { message: 'Websocket worker cleanup failed' })
185
+ resolve()
186
+ })
187
+ })
188
+ }),
189
+ )
147
190
  }
148
191
  }
149
192
  }
package/src/index.ts CHANGED
@@ -123,7 +123,6 @@ export default class ActionsPlugin implements Plugin {
123
123
  )
124
124
  const actionsWebsocketMiddlewareFactory = new ActionsWebsocketControllerFactory(
125
125
  debugMode,
126
- actionsContextResolver,
127
126
  actions_dispatchWorkerSupervisorFactory,
128
127
  )
129
128
  it.addRoute('actions', '/actions/:projectSlug', actionsMiddlewareFactory.create())
@@ -0,0 +1,270 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { Runnable, Running } from '@contember/engine-common'
3
+ import { Application, Authenticator, ProjectGroupContainer, ProjectGroupResolver, serverConfigSchema } from '@contember/engine-http'
4
+ import { createLogger, TestLoggerHandler } from '@contember/logger'
5
+ import { TenantRole, VerifyResult } from '@contember/engine-tenant-api'
6
+ import { WebSocket } from 'ws'
7
+ import { ActionsWebsocketControllerFactory } from '../../../src/graphql/http/ActionsWebsocketControllerFactory.js'
8
+
9
+ type Interface<T> = { [P in keyof T]: T[P] }
10
+
11
+ const createMock = <T>(members: Interface<T>): T => members
12
+
13
+ const unavailable = (): never => {
14
+ throw new Error('This service is not available in the actions WebSocket controller test')
15
+ }
16
+
17
+ const waitFor = async (condition: () => boolean): Promise<void> => {
18
+ for (let attempt = 0; attempt < 100; attempt++) {
19
+ if (condition()) {
20
+ return
21
+ }
22
+ await new Promise<void>(resolve => setTimeout(resolve, 1))
23
+ }
24
+ throw new Error('Condition was not met')
25
+ }
26
+
27
+ const listen = async (createWorker: () => Runnable) => {
28
+ const loggerHandler = new TestLoggerHandler()
29
+ const logger = createLogger(loggerHandler)
30
+ const authenticator = createMock<Authenticator>({
31
+ authenticate: async () => new VerifyResult('identity', 'api-key', [TenantRole.SUPER_ADMIN], null),
32
+ })
33
+ const projectGroup: ProjectGroupContainer = {
34
+ slug: undefined,
35
+ logger,
36
+ authenticator,
37
+ get projectMembershipResolver() {
38
+ return unavailable()
39
+ },
40
+ get projectContainerResolver() {
41
+ return unavailable()
42
+ },
43
+ get projectSchemaResolver() {
44
+ return unavailable()
45
+ },
46
+ get projectInitializer() {
47
+ return unavailable()
48
+ },
49
+ get tenantContainer() {
50
+ return unavailable()
51
+ },
52
+ get tenantGraphQLHandler() {
53
+ return unavailable()
54
+ },
55
+ get systemContainer() {
56
+ return unavailable()
57
+ },
58
+ get systemGraphQLHandler() {
59
+ return unavailable()
60
+ },
61
+ }
62
+ const projectGroupResolver = createMock<ProjectGroupResolver>({
63
+ resolveContainer: async () => projectGroup,
64
+ })
65
+ const application = new Application(projectGroupResolver, serverConfigSchema({ port: 0 }), false, undefined, logger)
66
+ application.addWebsocketRoute(
67
+ 'actions',
68
+ '/actions/_worker',
69
+ new ActionsWebsocketControllerFactory(false, { create: createWorker }).create(),
70
+ )
71
+ const running = await application.listen()
72
+ const address = running.server.address()
73
+ if (address === null || typeof address === 'string') {
74
+ await running.close()
75
+ throw new Error('The test server did not bind to a TCP port')
76
+ }
77
+
78
+ const messages: string[] = []
79
+ const client = new WebSocket(`ws://127.0.0.1:${address.port}/actions/_worker`)
80
+ client.on('message', data => {
81
+ const message: unknown = JSON.parse(data.toString())
82
+ if (typeof message === 'object' && message !== null && 'type' in message && typeof message.type === 'string') {
83
+ const detail = 'message' in message && typeof message.message === 'string' ? `:${message.message}` : ''
84
+ messages.push(`${message.type}${detail}`)
85
+ }
86
+ })
87
+ const opened = new Promise<void>((resolve, reject) => {
88
+ client.once('open', () => resolve())
89
+ client.once('error', reject)
90
+ })
91
+ const closed = new Promise<number>(resolve => client.once('close', code => resolve(code)))
92
+ return { client, closed, loggerHandler, messages, opened, running }
93
+ }
94
+
95
+ test('a worker startup failure does not block WebSocket shutdown', async () => {
96
+ const worker: Runnable = {
97
+ run: (): Promise<Running> => Promise.reject(new Error('Worker startup failed')),
98
+ }
99
+ const { client, closed, loggerHandler, messages, opened, running } = await listen(() => worker)
100
+ let applicationClosed = false
101
+
102
+ try {
103
+ await opened
104
+ await waitFor(() => messages.includes('ready'))
105
+ client.send(JSON.stringify({ type: 'startWorker' }))
106
+ await waitFor(() => messages.includes('workerFailedToStart'))
107
+
108
+ await running.close()
109
+ applicationClosed = true
110
+ expect(await closed).toBe(1012)
111
+ expect(loggerHandler.messages.some(it => it.message.startsWith('Websocket worker cleanup failed'))).toBe(false)
112
+ } finally {
113
+ client.terminate()
114
+ if (!applicationClosed) {
115
+ await running.close()
116
+ }
117
+ }
118
+ })
119
+
120
+ test('shutdown waits for every worker cleanup after one cleanup fails', async () => {
121
+ let releaseCleanup = () => {}
122
+ const cleanupReleased = new Promise<void>(resolve => {
123
+ releaseCleanup = resolve
124
+ })
125
+ let cleanupStarted = false
126
+ const workers: Runnable[] = [
127
+ {
128
+ run: async () => ({
129
+ end: (): Promise<void> => Promise.reject(new Error('Worker cleanup failed')),
130
+ }),
131
+ },
132
+ {
133
+ run: async () => ({
134
+ end: async () => {
135
+ cleanupStarted = true
136
+ await cleanupReleased
137
+ },
138
+ }),
139
+ },
140
+ ]
141
+ const { client, closed, loggerHandler, messages, opened, running } = await listen(() => workers.shift() ?? unavailable())
142
+ let shutdown: Promise<void> | undefined
143
+
144
+ try {
145
+ await opened
146
+ await waitFor(() => messages.includes('ready'))
147
+ client.send(JSON.stringify({ type: 'startWorker' }))
148
+ await waitFor(() => messages.filter(it => it === 'workerStarted').length === 1)
149
+ client.send(JSON.stringify({ type: 'startWorker' }))
150
+ await waitFor(() => messages.filter(it => it === 'workerStarted').length === 2)
151
+
152
+ let shutdownFinished = false
153
+ shutdown = running.close().then(() => {
154
+ shutdownFinished = true
155
+ })
156
+ await waitFor(() => cleanupStarted)
157
+ await new Promise<void>(resolve => setImmediate(resolve))
158
+ expect(shutdownFinished).toBe(false)
159
+ expect(messages).not.toContain('message:closing connection')
160
+ releaseCleanup()
161
+ await shutdown
162
+ expect(await closed).toBe(1012)
163
+ expect(loggerHandler.messages.some(it => it.message.startsWith('Websocket worker cleanup failed'))).toBe(true)
164
+ } finally {
165
+ releaseCleanup()
166
+ shutdown ??= running.close()
167
+ await shutdown
168
+ client.terminate()
169
+ }
170
+ })
171
+
172
+ test('shutdown waits for worker cleanup started by a peer disconnect', async () => {
173
+ let releaseCleanup = () => {}
174
+ const cleanupReleased = new Promise<void>(resolve => {
175
+ releaseCleanup = resolve
176
+ })
177
+ let cleanupStarted = false
178
+ const worker: Runnable = {
179
+ run: async () => ({
180
+ end: async () => {
181
+ cleanupStarted = true
182
+ await cleanupReleased
183
+ },
184
+ }),
185
+ }
186
+ const { client, messages, opened, running } = await listen(() => worker)
187
+ let shutdown: Promise<void> | undefined
188
+
189
+ try {
190
+ await opened
191
+ await waitFor(() => messages.includes('ready'))
192
+ client.send(JSON.stringify({ type: 'startWorker' }))
193
+ await waitFor(() => messages.includes('workerStarted'))
194
+ client.close()
195
+ await waitFor(() => cleanupStarted)
196
+
197
+ let shutdownFinished = false
198
+ shutdown = running.close().then(() => {
199
+ shutdownFinished = true
200
+ })
201
+ await new Promise<void>(resolve => setImmediate(resolve))
202
+ expect(shutdownFinished).toBe(false)
203
+ releaseCleanup()
204
+ await shutdown
205
+ } finally {
206
+ releaseCleanup()
207
+ shutdown ??= running.close()
208
+ await shutdown
209
+ client.terminate()
210
+ }
211
+ })
212
+
213
+ test('a worker whose cleanup fails stays tracked for the next stop', async () => {
214
+ const worker: Runnable = {
215
+ run: async () => ({
216
+ end: (): Promise<void> => Promise.reject(new Error('Worker cleanup failed')),
217
+ }),
218
+ }
219
+ const { client, closed, messages, opened, running } = await listen(() => worker)
220
+ let applicationClosed = false
221
+
222
+ try {
223
+ await opened
224
+ await waitFor(() => messages.includes('ready'))
225
+ client.send(JSON.stringify({ type: 'startWorker' }))
226
+ await waitFor(() => messages.includes('workerStarted'))
227
+ client.send(JSON.stringify({ type: 'stopAllWorkers' }))
228
+ await waitFor(() => messages.filter(it => it === 'error:Worker cleanup failed').length === 1)
229
+ client.send(JSON.stringify({ type: 'stopAllWorkers' }))
230
+ await waitFor(() => messages.filter(it => it === 'error:Worker cleanup failed').length === 2)
231
+ expect(messages).not.toContain('message:all stopped')
232
+
233
+ await running.close()
234
+ applicationClosed = true
235
+ expect(await closed).toBe(1012)
236
+ } finally {
237
+ client.terminate()
238
+ if (!applicationClosed) {
239
+ await running.close()
240
+ }
241
+ }
242
+ })
243
+
244
+ test('reports a manual worker cleanup failure without blocking shutdown', async () => {
245
+ const worker: Runnable = {
246
+ run: async () => ({
247
+ end: (): Promise<void> => Promise.reject(new Error('Worker cleanup failed')),
248
+ }),
249
+ }
250
+ const { client, closed, messages, opened, running } = await listen(() => worker)
251
+ let applicationClosed = false
252
+
253
+ try {
254
+ await opened
255
+ await waitFor(() => messages.includes('ready'))
256
+ client.send(JSON.stringify({ type: 'startWorker' }))
257
+ await waitFor(() => messages.includes('workerStarted'))
258
+ client.send(JSON.stringify({ type: 'stopAllWorkers' }))
259
+ await waitFor(() => messages.includes('error:Worker cleanup failed'))
260
+
261
+ await running.close()
262
+ applicationClosed = true
263
+ expect(await closed).toBe(1012)
264
+ } finally {
265
+ client.terminate()
266
+ if (!applicationClosed) {
267
+ await running.close()
268
+ }
269
+ }
270
+ })