@backstage/plugin-events-backend 0.6.7-next.1 → 0.6.7-next.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @backstage/plugin-events-backend
2
2
 
3
+ ## 0.6.7-next.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 02fd3bf: Improve database event cleanup under large backlogs and speed up reading events for subscriptions with many retained events.
8
+ - Updated dependencies
9
+ - @backstage/backend-openapi-utils@0.7.3-next.2
10
+
3
11
  ## 0.6.7-next.1
4
12
 
5
13
  ### Patch Changes
package/README.md CHANGED
@@ -56,6 +56,16 @@ in combination with suitable event subscribers.
56
56
 
57
57
  However, it is not limited to these use cases.
58
58
 
59
+ ## Database event retention
60
+
61
+ The database-backed event bus uses PostgreSQL. Events are retained for at least
62
+ 10 minutes. Beyond that window, cleanup aims to keep the newest 10,000 events;
63
+ events older than one day are eligible for deletion regardless of count.
64
+ Cleanup runs every minute in batches, so bursts and backlogs can temporarily
65
+ leave more than 10,000 events. Subscriptions that have fallen behind the
66
+ retained events are also removed; consumers should be ready to recreate their
67
+ subscriptions if they have been inactive for too long.
68
+
59
69
  ## Use Cases
60
70
 
61
71
  ### Request Validator
@@ -8,6 +8,9 @@ const WINDOW_MAX_COUNT_DEFAULT = 1e4;
8
8
  const WINDOW_MIN_AGE_DEFAULT = { minutes: 10 };
9
9
  const WINDOW_MAX_AGE_DEFAULT = { days: 1 };
10
10
  const MAX_BATCH_SIZE = 10;
11
+ const CLEANUP_BATCH_SIZE = 1e3;
12
+ const CLEANUP_STATEMENT_TIMEOUT_MS = 2e4;
13
+ const CLEANUP_RUN_DURATION_MS = 5e4;
11
14
  const LISTENER_CONNECTION_TIMEOUT_MS = 6e4;
12
15
  const KEEPALIVE_INTERVAL_MS = 6e4;
13
16
  const TABLE_EVENTS = "event_bus_events";
@@ -196,10 +199,11 @@ class DatabaseEventBusStore {
196
199
  );
197
200
  await options.scheduler.scheduleTask({
198
201
  id: "event-bus-cleanup",
199
- frequency: { seconds: 10 },
202
+ scope: "global",
203
+ frequency: { minutes: 1 },
200
204
  timeout: { minutes: 1 },
201
205
  initialDelay: { seconds: 10 },
202
- fn: () => store.#cleanup()
206
+ fn: (signal) => store.#cleanup(signal)
203
207
  });
204
208
  options.lifecycle.addShutdownHook(async () => {
205
209
  await listener.shutdown();
@@ -222,7 +226,9 @@ class DatabaseEventBusStore {
222
226
  minAge,
223
227
  maxAge
224
228
  );
225
- return Object.assign(store, { clean: () => store.#cleanup() });
229
+ return Object.assign(store, {
230
+ clean: (signal) => store.#cleanup(signal)
231
+ });
226
232
  }
227
233
  #db;
228
234
  #logger;
@@ -308,20 +314,30 @@ class DatabaseEventBusStore {
308
314
  FOR UPDATE
309
315
  ),
310
316
  selected_events AS (
311
- SELECT event_bus_events.*
312
- FROM event_bus_events
313
- INNER JOIN subscription
314
- ON event_bus_events.topic = ANY(subscription.topics)
315
- WHERE event_bus_events.id > subscription.read_until
316
- AND NOT :id = ANY(event_bus_events.notified_subscribers)
317
- ORDER BY event_bus_events.id ASC LIMIT :limit
317
+ SELECT event.*
318
+ FROM subscription
319
+ CROSS JOIN LATERAL (
320
+ SELECT DISTINCT topic
321
+ FROM unnest(subscription.topics) AS subscribed(topic)
322
+ ) AS subscribed_topics
323
+ CROSS JOIN LATERAL (
324
+ SELECT event_bus_events.*
325
+ FROM event_bus_events
326
+ WHERE event_bus_events.topic = subscribed_topics.topic
327
+ AND event_bus_events.id > subscription.read_until
328
+ AND NOT :id = ANY(event_bus_events.notified_subscribers)
329
+ ORDER BY event_bus_events.id ASC
330
+ LIMIT :limit
331
+ ) AS event
332
+ ORDER BY event.id ASC
333
+ LIMIT :limit
318
334
  ),
319
335
  last_event_id AS (
320
336
  SELECT max(id) AS last_event_id
321
337
  FROM selected_events
322
338
  ),
323
339
  events_array AS (
324
- SELECT json_agg(row_to_json(selected_events)) AS events
340
+ SELECT json_agg(row_to_json(selected_events) ORDER BY id) AS events
325
341
  FROM selected_events
326
342
  )
327
343
  UPDATE event_bus_subscriptions
@@ -367,41 +383,134 @@ class DatabaseEventBusStore {
367
383
  options.signal
368
384
  );
369
385
  }
370
- async #cleanup() {
371
- try {
372
- const eventCount = await this.#db(TABLE_EVENTS).delete().orWhere(
373
- (inner) => inner.whereIn(
374
- "id",
375
- this.#db.select("id").from(TABLE_EVENTS).orderBy("id", "desc").offset(this.#windowMaxCount)
376
- ).andWhere(
377
- "created_at",
378
- "<",
379
- new Date(Date.now() - this.#windowMinAge)
380
- )
381
- ).orWhere("created_at", "<", new Date(Date.now() - this.#windowMaxAge));
382
- if (eventCount > 0) {
383
- this.#logger.info(
384
- `Event cleanup resulted in ${eventCount} old events being deleted`
385
- );
386
- }
387
- } catch (error) {
388
- this.#logger.error("Event cleanup failed", error);
386
+ async #cleanup(signal) {
387
+ if (signal?.aborted) {
388
+ return;
389
389
  }
390
- try {
391
- const [{ min: minId }] = await this.#db(TABLE_EVENTS).min("id");
392
- let subscriberCount;
393
- if (minId === null) {
394
- subscriberCount = await this.#db(TABLE_SUBSCRIPTIONS).where("updated_at", "<", new Date(Date.now() - this.#windowMaxAge)).delete();
395
- } else {
396
- subscriberCount = await this.#db(TABLE_SUBSCRIPTIONS).delete().where("read_until", "<", minId - 1);
390
+ const stopAt = Date.now() + CLEANUP_RUN_DURATION_MS;
391
+ let eventCount = 0;
392
+ let subscriberCount = 0;
393
+ const setStatementTimeout = async (trx) => {
394
+ const remainingMs = stopAt - Date.now();
395
+ if (remainingMs <= 0) {
396
+ return false;
397
397
  }
398
- if (subscriberCount > 0) {
399
- this.#logger.info(
400
- `Subscription cleanup resulted in ${subscriberCount} stale subscribers being deleted`
398
+ await trx.raw(
399
+ `SET LOCAL statement_timeout = '${Math.min(
400
+ CLEANUP_STATEMENT_TIMEOUT_MS,
401
+ remainingMs
402
+ )}ms'`
403
+ );
404
+ return true;
405
+ };
406
+ const deleteEventBatch = (createdBefore, maxId) => {
407
+ const bindings = [createdBefore];
408
+ if (maxId !== void 0) {
409
+ bindings.push(maxId);
410
+ }
411
+ bindings.push(CLEANUP_BATCH_SIZE);
412
+ return this.#db.transaction(async (trx) => {
413
+ if (!await setStatementTimeout(trx)) {
414
+ return 0;
415
+ }
416
+ const result = await trx.raw(
417
+ `WITH candidates AS (
418
+ SELECT id FROM event_bus_events
419
+ WHERE created_at < ? ${maxId === void 0 ? "" : "AND id <= ?"}
420
+ ORDER BY created_at, id
421
+ LIMIT ? FOR UPDATE SKIP LOCKED
422
+ )
423
+ DELETE FROM event_bus_events
424
+ WHERE id = ANY (ARRAY(SELECT id FROM candidates))`,
425
+ bindings
401
426
  );
427
+ return result.rowCount;
428
+ });
429
+ };
430
+ const deleteSubscriberBatch = () => this.#db.transaction(async (trx) => {
431
+ if (!await setStatementTimeout(trx)) {
432
+ return 0;
402
433
  }
403
- } catch (error) {
404
- this.#logger.error("Subscription cleanup failed", error);
434
+ const [{ min: minId }] = await trx(TABLE_EVENTS).min("id");
435
+ if (!await setStatementTimeout(trx)) {
436
+ return 0;
437
+ }
438
+ const where = minId === null ? "updated_at < ?" : "read_until < CAST(? AS bigint) - 1";
439
+ const binding = minId === null ? new Date(Date.now() - this.#windowMaxAge) : minId;
440
+ const result = await trx.raw(
441
+ `WITH candidates AS (
442
+ SELECT id FROM event_bus_subscriptions
443
+ WHERE ${where}
444
+ ORDER BY id
445
+ LIMIT ? FOR UPDATE SKIP LOCKED
446
+ )
447
+ DELETE FROM event_bus_subscriptions
448
+ WHERE id = ANY (ARRAY(SELECT id FROM candidates))`,
449
+ [binding, CLEANUP_BATCH_SIZE]
450
+ );
451
+ return result.rowCount;
452
+ });
453
+ let eventCleanupPhase = "maxAge";
454
+ let countCutoff;
455
+ let failed = false;
456
+ while (!failed && !signal?.aborted && Date.now() < stopAt) {
457
+ let subscriberDeleted = 0;
458
+ try {
459
+ if (eventCleanupPhase === "maxAge") {
460
+ const deleted = await deleteEventBatch(
461
+ new Date(Date.now() - this.#windowMaxAge)
462
+ );
463
+ eventCount += deleted;
464
+ if (deleted < CLEANUP_BATCH_SIZE) {
465
+ eventCleanupPhase = "maxCount";
466
+ }
467
+ } else if (eventCleanupPhase === "maxCount") {
468
+ if (countCutoff === void 0) {
469
+ const cutoff = await this.#db.transaction(async (trx) => {
470
+ if (!await setStatementTimeout(trx)) {
471
+ return void 0;
472
+ }
473
+ return trx(TABLE_EVENTS).select("id").orderBy("id", "desc").offset(this.#windowMaxCount).first();
474
+ });
475
+ countCutoff = cutoff?.id;
476
+ }
477
+ if (countCutoff === void 0) {
478
+ eventCleanupPhase = "complete";
479
+ } else {
480
+ const deleted = await deleteEventBatch(
481
+ new Date(Date.now() - this.#windowMinAge),
482
+ countCutoff
483
+ );
484
+ eventCount += deleted;
485
+ if (deleted < CLEANUP_BATCH_SIZE) {
486
+ eventCleanupPhase = "complete";
487
+ }
488
+ }
489
+ }
490
+ } catch (error) {
491
+ this.#logger.error("Event cleanup failed", error);
492
+ failed = true;
493
+ }
494
+ try {
495
+ subscriberDeleted = await deleteSubscriberBatch();
496
+ subscriberCount += subscriberDeleted;
497
+ } catch (error) {
498
+ this.#logger.error("Subscription cleanup failed", error);
499
+ failed = true;
500
+ }
501
+ if (eventCleanupPhase === "complete" && subscriberDeleted < CLEANUP_BATCH_SIZE) {
502
+ break;
503
+ }
504
+ }
505
+ if (eventCount > 0) {
506
+ this.#logger.info(
507
+ `Event cleanup resulted in ${eventCount} old events being deleted`
508
+ );
509
+ }
510
+ if (subscriberCount > 0) {
511
+ this.#logger.info(
512
+ `Subscription cleanup resulted in ${subscriberCount} stale subscribers being deleted`
513
+ );
405
514
  }
406
515
  }
407
516
  }
@@ -1 +1 @@
1
- {"version":3,"file":"DatabaseEventBusStore.cjs.js","sources":["../../../src/service/hub/DatabaseEventBusStore.ts"],"sourcesContent":["/*\n * Copyright 2024 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { EventParams } from '@backstage/plugin-events-node';\nimport { EventBusStore } from './types';\nimport { Knex } from 'knex';\nimport {\n BackstageCredentials,\n BackstageServicePrincipal,\n DatabaseService,\n LifecycleService,\n LoggerService,\n SchedulerService,\n resolvePackagePath,\n} from '@backstage/backend-plugin-api';\nimport { ForwardedError, NotFoundError } from '@backstage/errors';\nimport { HumanDuration, durationToMilliseconds } from '@backstage/types';\n\nconst WINDOW_MAX_COUNT_DEFAULT = 10_000;\nconst WINDOW_MIN_AGE_DEFAULT = { minutes: 10 };\nconst WINDOW_MAX_AGE_DEFAULT = { days: 1 };\n\nconst MAX_BATCH_SIZE = 10;\nconst LISTENER_CONNECTION_TIMEOUT_MS = 60_000;\nconst KEEPALIVE_INTERVAL_MS = 60_000;\n\nconst TABLE_EVENTS = 'event_bus_events';\nconst TABLE_SUBSCRIPTIONS = 'event_bus_subscriptions';\nconst TOPIC_PUBLISH = 'event_bus_publish';\n\ntype EventsRow = {\n id: string;\n created_by: string;\n created_at: Date;\n topic: string;\n data_json: string;\n notified_subscribers: string[];\n};\n\ntype SubscriptionsRow = {\n id: string;\n created_by: string;\n created_at: Date;\n updated_at: Date;\n read_until: string;\n topics: string[];\n};\n\nfunction creatorId(\n credentials: BackstageCredentials<BackstageServicePrincipal>,\n) {\n return `service=${credentials.principal.subject}`;\n}\n\nconst migrationsDir = resolvePackagePath(\n '@backstage/plugin-events-backend',\n 'migrations',\n);\n\ninterface InternalDbClient {\n acquireRawConnection(): Promise<InternalDbConnection>;\n destroyRawConnection(conn: InternalDbConnection): Promise<void>;\n}\n\ninterface InternalDbConnection {\n query(sql: string): Promise<void>;\n end(): Promise<void>;\n on(\n event: 'notification',\n listener: (event: { channel: string; payload: string }) => void,\n ): void;\n on(event: 'error', listener: (error: Error) => void): void;\n on(event: 'end', listener: (error?: Error) => void): void;\n removeAllListeners(): void;\n}\n\n// This internal class manages a single connection to the database that all listeners share\nclass DatabaseEventBusListener {\n readonly #client: InternalDbClient;\n readonly #logger: LoggerService;\n\n readonly #listeners = new Set<{\n topics: Set<string>;\n resolve: (result: { topic: string }) => void;\n reject: (error: Error) => void;\n }>();\n\n #isShuttingDown = false;\n #connPromise?: Promise<InternalDbConnection>;\n #connTimeout?: NodeJS.Timeout;\n #keepaliveInterval?: NodeJS.Timeout;\n\n constructor(client: InternalDbClient, logger: LoggerService) {\n this.#client = client;\n this.#logger = logger.child({ type: 'DatabaseEventBusListener' });\n }\n\n async setupListener(\n topics: Set<string>,\n signal: AbortSignal,\n ): Promise<{ waitForUpdate(): Promise<{ topic: string }> }> {\n if (this.#connTimeout) {\n clearTimeout(this.#connTimeout);\n this.#connTimeout = undefined;\n }\n\n await this.#ensureConnection();\n\n const updatePromise = new Promise<{ topic: string }>((resolve, reject) => {\n const listener = {\n topics,\n resolve(result: { topic: string }) {\n resolve(result);\n cleanup();\n },\n reject(err: Error) {\n reject(err);\n cleanup();\n },\n };\n this.#listeners.add(listener);\n\n const onAbort = () => {\n this.#listeners.delete(listener);\n this.#maybeTimeoutConnection();\n reject(signal.reason);\n cleanup();\n };\n\n function cleanup() {\n signal.removeEventListener('abort', onAbort);\n }\n\n signal.addEventListener('abort', onAbort);\n });\n\n // Ignore unhandled rejections\n updatePromise.catch(() => {});\n\n return { waitForUpdate: () => updatePromise };\n }\n\n async shutdown() {\n if (this.#isShuttingDown) {\n return;\n }\n this.#isShuttingDown = true;\n const conn = await this.#connPromise?.catch(() => undefined);\n if (conn) {\n this.#destroyConnection(conn);\n }\n }\n\n #handleNotify(topic: string) {\n this.#logger.debug(`Listener received notification for topic '${topic}'`);\n for (const l of this.#listeners) {\n if (l.topics.has(topic)) {\n l.resolve({ topic });\n this.#listeners.delete(l);\n }\n }\n this.#maybeTimeoutConnection();\n }\n\n // We don't try to reconnect on error, instead we notify all listeners and let\n // them try to establish a new connection\n #handleError(error: Error) {\n this.#logger.error(\n `Listener connection failed, notifying all listeners`,\n error,\n );\n for (const l of this.#listeners) {\n l.reject(new Error('Listener connection failed'));\n }\n this.#listeners.clear();\n this.#maybeTimeoutConnection();\n }\n\n #maybeTimeoutConnection() {\n // If we don't have any listeners, destroy the connection after a timeout\n if (this.#listeners.size === 0 && !this.#connTimeout) {\n this.#connTimeout = setTimeout(() => {\n this.#connTimeout = undefined;\n this.#connPromise?.then(conn => {\n this.#logger.info('Listener connection timed out, destroying');\n this.#connPromise = undefined;\n this.#destroyConnection(conn);\n });\n }, LISTENER_CONNECTION_TIMEOUT_MS);\n }\n }\n\n #destroyConnection(conn: InternalDbConnection) {\n if (this.#keepaliveInterval) {\n clearInterval(this.#keepaliveInterval);\n this.#keepaliveInterval = undefined;\n }\n this.#client.destroyRawConnection(conn).catch(error => {\n this.#logger.error(`Listener failed to destroy connection`, error);\n });\n conn.removeAllListeners();\n }\n\n async #ensureConnection() {\n if (this.#isShuttingDown) {\n throw new Error('Listener is shutting down');\n }\n if (this.#connPromise) {\n await this.#connPromise;\n return;\n }\n this.#connPromise = Promise.resolve().then(async () => {\n const conn = await this.#client.acquireRawConnection();\n\n try {\n await conn.query(`LISTEN ${TOPIC_PUBLISH}`);\n\n // Set up a keepalive interval to make sure the connection stays alive\n if (this.#keepaliveInterval) {\n clearInterval(this.#keepaliveInterval);\n }\n this.#keepaliveInterval = setInterval(() => {\n conn.query('select 1').catch(error => {\n this.#connPromise = undefined;\n this.#destroyConnection(conn);\n this.#handleError(new ForwardedError('Keepalive failed', error));\n });\n }, KEEPALIVE_INTERVAL_MS);\n\n conn.on('notification', event => {\n this.#handleNotify(event.payload);\n });\n conn.on('error', error => {\n this.#connPromise = undefined;\n this.#destroyConnection(conn);\n this.#handleError(error);\n });\n conn.on('end', error => {\n this.#connPromise = undefined;\n this.#destroyConnection(conn);\n this.#handleError(\n error ?? new Error('Connection ended unexpectedly'),\n );\n });\n return conn;\n } catch (error) {\n this.#destroyConnection(conn);\n throw error;\n }\n });\n try {\n await this.#connPromise;\n } catch (error) {\n this.#connPromise = undefined;\n throw error;\n }\n }\n}\n\nexport class DatabaseEventBusStore implements EventBusStore {\n static async create(options: {\n database: DatabaseService;\n logger: LoggerService;\n scheduler: SchedulerService;\n lifecycle: LifecycleService;\n window?: {\n /** Events within this range will never be deleted */\n minAge?: HumanDuration;\n /** Events outside of this age will always be deleted */\n maxAge?: HumanDuration;\n /** Events outside of this count will be deleted if they are outside the minAge window */\n maxCount?: number;\n };\n }): Promise<DatabaseEventBusStore> {\n const db = await options.database.getClient();\n\n if (db.client.config.client !== 'pg') {\n throw new Error(\n `DatabaseEventBusStore only supports PostgreSQL, got '${db.client.config.client}'`,\n );\n }\n\n if (!options.database.migrations?.skip) {\n await db.migrate.latest({\n directory: migrationsDir,\n });\n }\n\n const listener = new DatabaseEventBusListener(db.client, options.logger);\n\n const store = new DatabaseEventBusStore(\n db,\n options.logger,\n listener,\n options.window?.maxCount ?? WINDOW_MAX_COUNT_DEFAULT,\n durationToMilliseconds(options.window?.minAge ?? WINDOW_MIN_AGE_DEFAULT),\n durationToMilliseconds(options.window?.maxAge ?? WINDOW_MAX_AGE_DEFAULT),\n );\n\n await options.scheduler.scheduleTask({\n id: 'event-bus-cleanup',\n frequency: { seconds: 10 },\n timeout: { minutes: 1 },\n initialDelay: { seconds: 10 },\n fn: () => store.#cleanup(),\n });\n\n options.lifecycle.addShutdownHook(async () => {\n await listener.shutdown();\n });\n\n return store;\n }\n\n /** @internal */\n static async forTest({\n db,\n logger,\n minAge = 0,\n maxAge = 10_000,\n }: {\n db: Knex;\n logger: LoggerService;\n minAge?: number;\n maxAge?: number;\n }) {\n await db.migrate.latest({ directory: migrationsDir });\n\n const store = new DatabaseEventBusStore(\n db,\n logger,\n new DatabaseEventBusListener(db.client, logger),\n 5,\n minAge,\n maxAge,\n );\n\n return Object.assign(store, { clean: () => store.#cleanup() });\n }\n\n readonly #db: Knex;\n readonly #logger: LoggerService;\n readonly #listener: DatabaseEventBusListener;\n readonly #windowMaxCount: number;\n readonly #windowMinAge: number;\n readonly #windowMaxAge: number;\n\n private constructor(\n db: Knex,\n logger: LoggerService,\n listener: DatabaseEventBusListener,\n windowMaxCount: number,\n windowMinAge: number,\n windowMaxAge: number,\n ) {\n this.#db = db;\n this.#logger = logger;\n this.#listener = listener;\n this.#windowMaxCount = windowMaxCount;\n this.#windowMinAge = windowMinAge;\n this.#windowMaxAge = windowMaxAge;\n }\n\n async publish(options: {\n event: EventParams;\n notifiedSubscribers?: string[];\n credentials: BackstageCredentials<BackstageServicePrincipal>;\n }): Promise<{ eventId: string } | undefined> {\n const topic = options.event.topic;\n const notifiedSubscribers = options.notifiedSubscribers ?? [];\n // This query inserts a new event into the database, but only if there are\n // subscribers to the topic that have not already been notified\n const result = await this.#db\n // There's no clean way to create a INSERT INTO .. SELECT with knex, so we end up with quite a lot of .raw(...)\n .into(\n this.#db.raw('?? (??, ??, ??, ??)', [\n TABLE_EVENTS,\n // These are the rows that we insert, and should match the SELECT below\n 'created_by',\n 'topic',\n 'data_json',\n 'notified_subscribers',\n ]),\n )\n .insert<EventsRow>(\n (q: Knex.QueryBuilder) =>\n q\n // We're not reading data to insert from anywhere else, just raw data\n .select(\n this.#db.raw('?', [creatorId(options.credentials)]),\n this.#db.raw('?', [topic]),\n this.#db.raw('?', [\n JSON.stringify({\n payload: options.event.eventPayload,\n metadata: options.event.metadata,\n }),\n ]),\n this.#db.raw('?', [notifiedSubscribers]),\n )\n // The rest of this query is to check whether there are any\n // subscribers that have not been notified yet\n .from(TABLE_SUBSCRIPTIONS)\n .whereNotIn('id', notifiedSubscribers) // Skip notified subscribers\n .andWhere(this.#db.raw('? = ANY(topics)', [topic])) // Match topic\n .having(this.#db.raw('count(*)'), '>', 0), // Check if there are any results\n )\n .returning<{ id: string }[]>('id');\n\n if (result.length === 0) {\n return undefined;\n }\n if (result.length > 1) {\n throw new Error(\n `Failed to insert event, unexpectedly updated ${result.length} rows`,\n );\n }\n\n const [{ id }] = result;\n\n // Notify other event bus instances that an event is available on the topic\n const notifyResult = await this.#db.select(\n this.#db.raw(`pg_notify(?, ?)`, [TOPIC_PUBLISH, topic]),\n );\n if (notifyResult?.length !== 1) {\n this.#logger.warn(\n `Failed to notify subscribers of event with ID '${id}' on topic '${topic}'`,\n );\n }\n\n return { eventId: id };\n }\n\n async upsertSubscription(\n id: string,\n topics: string[],\n credentials: BackstageCredentials<BackstageServicePrincipal>,\n ): Promise<void> {\n const [{ max: maxId }] = await this.#db(TABLE_EVENTS).max('id');\n const result = await this.#db<SubscriptionsRow>(TABLE_SUBSCRIPTIONS)\n .insert({\n id,\n created_by: creatorId(credentials),\n updated_at: this.#db.fn.now(),\n topics,\n read_until: maxId || 0,\n })\n .onConflict('id')\n .merge(['created_by', 'topics', 'updated_at'])\n .returning('*');\n\n if (result.length !== 1) {\n throw new Error(\n `Failed to upsert subscription, updated ${result.length} rows`,\n );\n }\n }\n\n async readSubscription(id: string): Promise<{ events: EventParams[] }> {\n // The below query selects the subscription we're reading from, locks it for\n // an update, reads events for the subscription up to the limit, and then\n // updates the pointer to the last read event.\n //\n // This is written as a plain SQL query to spare us all the horrors of\n // expressing this in knex.\n\n const { rows: result } = await this.#db.raw<{\n rows: [] | [{ events: EventsRow[] }];\n }>(\n `\n WITH subscription AS (\n SELECT topics, read_until\n FROM event_bus_subscriptions\n WHERE id = :id\n FOR UPDATE\n ),\n selected_events AS (\n SELECT event_bus_events.*\n FROM event_bus_events\n INNER JOIN subscription\n ON event_bus_events.topic = ANY(subscription.topics)\n WHERE event_bus_events.id > subscription.read_until\n AND NOT :id = ANY(event_bus_events.notified_subscribers)\n ORDER BY event_bus_events.id ASC LIMIT :limit\n ),\n last_event_id AS (\n SELECT max(id) AS last_event_id\n FROM selected_events\n ),\n events_array AS (\n SELECT json_agg(row_to_json(selected_events)) AS events\n FROM selected_events\n )\n UPDATE event_bus_subscriptions\n SET read_until = COALESCE(last_event_id, (SELECT MAX(id) FROM event_bus_events), 0)\n FROM events_array, last_event_id\n WHERE event_bus_subscriptions.id = :id\n RETURNING events_array.events\n `,\n { id, limit: MAX_BATCH_SIZE },\n );\n\n if (result.length === 0) {\n throw new NotFoundError(`Subscription with ID '${id}' not found`);\n } else if (result.length > 1) {\n throw new Error(\n `Failed to read subscription, unexpectedly updated ${result.length} rows`,\n );\n }\n\n const rows = result[0].events;\n if (!rows || rows.length === 0) {\n return { events: [] };\n }\n\n return {\n events: rows.map(row => {\n const { payload, metadata } = JSON.parse(row.data_json);\n return {\n topic: row.topic,\n eventPayload: payload,\n metadata,\n };\n }),\n };\n }\n\n async setupListener(\n subscriptionId: string,\n options: {\n signal: AbortSignal;\n },\n ): Promise<{ waitForUpdate(): Promise<{ topic: string }> }> {\n const result = await this.#db<SubscriptionsRow>(TABLE_SUBSCRIPTIONS)\n .select('topics')\n .where({ id: subscriptionId })\n .first();\n\n if (!result) {\n throw new NotFoundError(\n `Subscription with ID '${subscriptionId}' not found`,\n );\n }\n\n options.signal.throwIfAborted();\n\n return this.#listener.setupListener(\n new Set(result.topics ?? []),\n options.signal,\n );\n }\n\n async #cleanup() {\n try {\n const eventCount = await this.#db(TABLE_EVENTS)\n .delete()\n // Delete any events that are outside both the min age and size window\n .orWhere(inner =>\n inner\n .whereIn(\n 'id',\n this.#db\n .select('id')\n .from(TABLE_EVENTS)\n .orderBy('id', 'desc')\n .offset(this.#windowMaxCount),\n )\n .andWhere(\n 'created_at',\n '<',\n new Date(Date.now() - this.#windowMinAge),\n ),\n )\n // If events are outside the max age they will always be deleted\n .orWhere('created_at', '<', new Date(Date.now() - this.#windowMaxAge));\n\n if (eventCount > 0) {\n this.#logger.info(\n `Event cleanup resulted in ${eventCount} old events being deleted`,\n );\n }\n } catch (error) {\n this.#logger.error('Event cleanup failed', error);\n }\n\n try {\n // Delete any subscribers that aren't keeping up with current events\n const [{ min: minId }] = await this.#db(TABLE_EVENTS).min('id');\n\n let subscriberCount;\n if (minId === null) {\n // No events left, remove all subscribers. This can happen if no events\n // are published within the max age window.\n subscriberCount = await this.#db(TABLE_SUBSCRIPTIONS)\n .where('updated_at', '<', new Date(Date.now() - this.#windowMaxAge))\n .delete();\n } else {\n subscriberCount = await this.#db(TABLE_SUBSCRIPTIONS)\n .delete()\n // Read pointer points to the ID that has been read, so we need an additional offset\n .where('read_until', '<', minId - 1);\n }\n\n if (subscriberCount > 0) {\n this.#logger.info(\n `Subscription cleanup resulted in ${subscriberCount} stale subscribers being deleted`,\n );\n }\n } catch (error) {\n this.#logger.error('Subscription cleanup failed', error);\n }\n }\n}\n"],"names":["resolvePackagePath","ForwardedError","durationToMilliseconds","NotFoundError"],"mappings":"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"}
1
+ {"version":3,"file":"DatabaseEventBusStore.cjs.js","sources":["../../../src/service/hub/DatabaseEventBusStore.ts"],"sourcesContent":["/*\n * Copyright 2024 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { EventParams } from '@backstage/plugin-events-node';\nimport { EventBusStore } from './types';\nimport { Knex } from 'knex';\nimport {\n BackstageCredentials,\n BackstageServicePrincipal,\n DatabaseService,\n LifecycleService,\n LoggerService,\n SchedulerService,\n resolvePackagePath,\n} from '@backstage/backend-plugin-api';\nimport { ForwardedError, NotFoundError } from '@backstage/errors';\nimport { HumanDuration, durationToMilliseconds } from '@backstage/types';\n\nconst WINDOW_MAX_COUNT_DEFAULT = 10_000;\nconst WINDOW_MIN_AGE_DEFAULT = { minutes: 10 };\nconst WINDOW_MAX_AGE_DEFAULT = { days: 1 };\n\nconst MAX_BATCH_SIZE = 10;\nconst CLEANUP_BATCH_SIZE = 1_000;\nconst CLEANUP_STATEMENT_TIMEOUT_MS = 20_000;\nconst CLEANUP_RUN_DURATION_MS = 50_000;\nconst LISTENER_CONNECTION_TIMEOUT_MS = 60_000;\nconst KEEPALIVE_INTERVAL_MS = 60_000;\n\nconst TABLE_EVENTS = 'event_bus_events';\nconst TABLE_SUBSCRIPTIONS = 'event_bus_subscriptions';\nconst TOPIC_PUBLISH = 'event_bus_publish';\n\ntype EventsRow = {\n id: string;\n created_by: string;\n created_at: Date;\n topic: string;\n data_json: string;\n notified_subscribers: string[];\n};\n\ntype SubscriptionsRow = {\n id: string;\n created_by: string;\n created_at: Date;\n updated_at: Date;\n read_until: string;\n topics: string[];\n};\n\nfunction creatorId(\n credentials: BackstageCredentials<BackstageServicePrincipal>,\n) {\n return `service=${credentials.principal.subject}`;\n}\n\nconst migrationsDir = resolvePackagePath(\n '@backstage/plugin-events-backend',\n 'migrations',\n);\n\ninterface InternalDbClient {\n acquireRawConnection(): Promise<InternalDbConnection>;\n destroyRawConnection(conn: InternalDbConnection): Promise<void>;\n}\n\ninterface InternalDbConnection {\n query(sql: string): Promise<void>;\n end(): Promise<void>;\n on(\n event: 'notification',\n listener: (event: { channel: string; payload: string }) => void,\n ): void;\n on(event: 'error', listener: (error: Error) => void): void;\n on(event: 'end', listener: (error?: Error) => void): void;\n removeAllListeners(): void;\n}\n\n// This internal class manages a single connection to the database that all listeners share\nclass DatabaseEventBusListener {\n readonly #client: InternalDbClient;\n readonly #logger: LoggerService;\n\n readonly #listeners = new Set<{\n topics: Set<string>;\n resolve: (result: { topic: string }) => void;\n reject: (error: Error) => void;\n }>();\n\n #isShuttingDown = false;\n #connPromise?: Promise<InternalDbConnection>;\n #connTimeout?: NodeJS.Timeout;\n #keepaliveInterval?: NodeJS.Timeout;\n\n constructor(client: InternalDbClient, logger: LoggerService) {\n this.#client = client;\n this.#logger = logger.child({ type: 'DatabaseEventBusListener' });\n }\n\n async setupListener(\n topics: Set<string>,\n signal: AbortSignal,\n ): Promise<{ waitForUpdate(): Promise<{ topic: string }> }> {\n if (this.#connTimeout) {\n clearTimeout(this.#connTimeout);\n this.#connTimeout = undefined;\n }\n\n await this.#ensureConnection();\n\n const updatePromise = new Promise<{ topic: string }>((resolve, reject) => {\n const listener = {\n topics,\n resolve(result: { topic: string }) {\n resolve(result);\n cleanup();\n },\n reject(err: Error) {\n reject(err);\n cleanup();\n },\n };\n this.#listeners.add(listener);\n\n const onAbort = () => {\n this.#listeners.delete(listener);\n this.#maybeTimeoutConnection();\n reject(signal.reason);\n cleanup();\n };\n\n function cleanup() {\n signal.removeEventListener('abort', onAbort);\n }\n\n signal.addEventListener('abort', onAbort);\n });\n\n // Ignore unhandled rejections\n updatePromise.catch(() => {});\n\n return { waitForUpdate: () => updatePromise };\n }\n\n async shutdown() {\n if (this.#isShuttingDown) {\n return;\n }\n this.#isShuttingDown = true;\n const conn = await this.#connPromise?.catch(() => undefined);\n if (conn) {\n this.#destroyConnection(conn);\n }\n }\n\n #handleNotify(topic: string) {\n this.#logger.debug(`Listener received notification for topic '${topic}'`);\n for (const l of this.#listeners) {\n if (l.topics.has(topic)) {\n l.resolve({ topic });\n this.#listeners.delete(l);\n }\n }\n this.#maybeTimeoutConnection();\n }\n\n // We don't try to reconnect on error, instead we notify all listeners and let\n // them try to establish a new connection\n #handleError(error: Error) {\n this.#logger.error(\n `Listener connection failed, notifying all listeners`,\n error,\n );\n for (const l of this.#listeners) {\n l.reject(new Error('Listener connection failed'));\n }\n this.#listeners.clear();\n this.#maybeTimeoutConnection();\n }\n\n #maybeTimeoutConnection() {\n // If we don't have any listeners, destroy the connection after a timeout\n if (this.#listeners.size === 0 && !this.#connTimeout) {\n this.#connTimeout = setTimeout(() => {\n this.#connTimeout = undefined;\n this.#connPromise?.then(conn => {\n this.#logger.info('Listener connection timed out, destroying');\n this.#connPromise = undefined;\n this.#destroyConnection(conn);\n });\n }, LISTENER_CONNECTION_TIMEOUT_MS);\n }\n }\n\n #destroyConnection(conn: InternalDbConnection) {\n if (this.#keepaliveInterval) {\n clearInterval(this.#keepaliveInterval);\n this.#keepaliveInterval = undefined;\n }\n this.#client.destroyRawConnection(conn).catch(error => {\n this.#logger.error(`Listener failed to destroy connection`, error);\n });\n conn.removeAllListeners();\n }\n\n async #ensureConnection() {\n if (this.#isShuttingDown) {\n throw new Error('Listener is shutting down');\n }\n if (this.#connPromise) {\n await this.#connPromise;\n return;\n }\n this.#connPromise = Promise.resolve().then(async () => {\n const conn = await this.#client.acquireRawConnection();\n\n try {\n await conn.query(`LISTEN ${TOPIC_PUBLISH}`);\n\n // Set up a keepalive interval to make sure the connection stays alive\n if (this.#keepaliveInterval) {\n clearInterval(this.#keepaliveInterval);\n }\n this.#keepaliveInterval = setInterval(() => {\n conn.query('select 1').catch(error => {\n this.#connPromise = undefined;\n this.#destroyConnection(conn);\n this.#handleError(new ForwardedError('Keepalive failed', error));\n });\n }, KEEPALIVE_INTERVAL_MS);\n\n conn.on('notification', event => {\n this.#handleNotify(event.payload);\n });\n conn.on('error', error => {\n this.#connPromise = undefined;\n this.#destroyConnection(conn);\n this.#handleError(error);\n });\n conn.on('end', error => {\n this.#connPromise = undefined;\n this.#destroyConnection(conn);\n this.#handleError(\n error ?? new Error('Connection ended unexpectedly'),\n );\n });\n return conn;\n } catch (error) {\n this.#destroyConnection(conn);\n throw error;\n }\n });\n try {\n await this.#connPromise;\n } catch (error) {\n this.#connPromise = undefined;\n throw error;\n }\n }\n}\n\nexport class DatabaseEventBusStore implements EventBusStore {\n static async create(options: {\n database: DatabaseService;\n logger: LoggerService;\n scheduler: SchedulerService;\n lifecycle: LifecycleService;\n window?: {\n /** Events within this range will never be deleted */\n minAge?: HumanDuration;\n /** Events outside of this age will always be deleted */\n maxAge?: HumanDuration;\n /** Events outside of this count will be deleted if they are outside the minAge window */\n maxCount?: number;\n };\n }): Promise<DatabaseEventBusStore> {\n const db = await options.database.getClient();\n\n if (db.client.config.client !== 'pg') {\n throw new Error(\n `DatabaseEventBusStore only supports PostgreSQL, got '${db.client.config.client}'`,\n );\n }\n\n if (!options.database.migrations?.skip) {\n await db.migrate.latest({\n directory: migrationsDir,\n });\n }\n\n const listener = new DatabaseEventBusListener(db.client, options.logger);\n\n const store = new DatabaseEventBusStore(\n db,\n options.logger,\n listener,\n options.window?.maxCount ?? WINDOW_MAX_COUNT_DEFAULT,\n durationToMilliseconds(options.window?.minAge ?? WINDOW_MIN_AGE_DEFAULT),\n durationToMilliseconds(options.window?.maxAge ?? WINDOW_MAX_AGE_DEFAULT),\n );\n\n await options.scheduler.scheduleTask({\n id: 'event-bus-cleanup',\n scope: 'global',\n frequency: { minutes: 1 },\n timeout: { minutes: 1 },\n initialDelay: { seconds: 10 },\n fn: signal => store.#cleanup(signal),\n });\n\n options.lifecycle.addShutdownHook(async () => {\n await listener.shutdown();\n });\n\n return store;\n }\n\n /** @internal */\n static async forTest({\n db,\n logger,\n minAge = 0,\n maxAge = 10_000,\n }: {\n db: Knex;\n logger: LoggerService;\n minAge?: number;\n maxAge?: number;\n }) {\n await db.migrate.latest({ directory: migrationsDir });\n\n const store = new DatabaseEventBusStore(\n db,\n logger,\n new DatabaseEventBusListener(db.client, logger),\n 5,\n minAge,\n maxAge,\n );\n\n return Object.assign(store, {\n clean: (signal?: AbortSignal) => store.#cleanup(signal),\n });\n }\n\n readonly #db: Knex;\n readonly #logger: LoggerService;\n readonly #listener: DatabaseEventBusListener;\n readonly #windowMaxCount: number;\n readonly #windowMinAge: number;\n readonly #windowMaxAge: number;\n\n private constructor(\n db: Knex,\n logger: LoggerService,\n listener: DatabaseEventBusListener,\n windowMaxCount: number,\n windowMinAge: number,\n windowMaxAge: number,\n ) {\n this.#db = db;\n this.#logger = logger;\n this.#listener = listener;\n this.#windowMaxCount = windowMaxCount;\n this.#windowMinAge = windowMinAge;\n this.#windowMaxAge = windowMaxAge;\n }\n\n async publish(options: {\n event: EventParams;\n notifiedSubscribers?: string[];\n credentials: BackstageCredentials<BackstageServicePrincipal>;\n }): Promise<{ eventId: string } | undefined> {\n const topic = options.event.topic;\n const notifiedSubscribers = options.notifiedSubscribers ?? [];\n // This query inserts a new event into the database, but only if there are\n // subscribers to the topic that have not already been notified\n const result = await this.#db\n // There's no clean way to create a INSERT INTO .. SELECT with knex, so we end up with quite a lot of .raw(...)\n .into(\n this.#db.raw('?? (??, ??, ??, ??)', [\n TABLE_EVENTS,\n // These are the rows that we insert, and should match the SELECT below\n 'created_by',\n 'topic',\n 'data_json',\n 'notified_subscribers',\n ]),\n )\n .insert<EventsRow>(\n (q: Knex.QueryBuilder) =>\n q\n // We're not reading data to insert from anywhere else, just raw data\n .select(\n this.#db.raw('?', [creatorId(options.credentials)]),\n this.#db.raw('?', [topic]),\n this.#db.raw('?', [\n JSON.stringify({\n payload: options.event.eventPayload,\n metadata: options.event.metadata,\n }),\n ]),\n this.#db.raw('?', [notifiedSubscribers]),\n )\n // The rest of this query is to check whether there are any\n // subscribers that have not been notified yet\n .from(TABLE_SUBSCRIPTIONS)\n .whereNotIn('id', notifiedSubscribers) // Skip notified subscribers\n .andWhere(this.#db.raw('? = ANY(topics)', [topic])) // Match topic\n .having(this.#db.raw('count(*)'), '>', 0), // Check if there are any results\n )\n .returning<{ id: string }[]>('id');\n\n if (result.length === 0) {\n return undefined;\n }\n if (result.length > 1) {\n throw new Error(\n `Failed to insert event, unexpectedly updated ${result.length} rows`,\n );\n }\n\n const [{ id }] = result;\n\n // Notify other event bus instances that an event is available on the topic\n const notifyResult = await this.#db.select(\n this.#db.raw(`pg_notify(?, ?)`, [TOPIC_PUBLISH, topic]),\n );\n if (notifyResult?.length !== 1) {\n this.#logger.warn(\n `Failed to notify subscribers of event with ID '${id}' on topic '${topic}'`,\n );\n }\n\n return { eventId: id };\n }\n\n async upsertSubscription(\n id: string,\n topics: string[],\n credentials: BackstageCredentials<BackstageServicePrincipal>,\n ): Promise<void> {\n const [{ max: maxId }] = await this.#db(TABLE_EVENTS).max('id');\n const result = await this.#db<SubscriptionsRow>(TABLE_SUBSCRIPTIONS)\n .insert({\n id,\n created_by: creatorId(credentials),\n updated_at: this.#db.fn.now(),\n topics,\n read_until: maxId || 0,\n })\n .onConflict('id')\n .merge(['created_by', 'topics', 'updated_at'])\n .returning('*');\n\n if (result.length !== 1) {\n throw new Error(\n `Failed to upsert subscription, updated ${result.length} rows`,\n );\n }\n }\n\n async readSubscription(id: string): Promise<{ events: EventParams[] }> {\n // The below query selects the subscription we're reading from, locks it for\n // an update, reads events for the subscription up to the limit, and then\n // updates the pointer to the last read event.\n //\n // This is written as a plain SQL query to spare us all the horrors of\n // expressing this in knex.\n\n const { rows: result } = await this.#db.raw<{\n rows: [] | [{ events: EventsRow[] }];\n }>(\n `\n WITH subscription AS (\n SELECT topics, read_until\n FROM event_bus_subscriptions\n WHERE id = :id\n FOR UPDATE\n ),\n selected_events AS (\n SELECT event.*\n FROM subscription\n CROSS JOIN LATERAL (\n SELECT DISTINCT topic\n FROM unnest(subscription.topics) AS subscribed(topic)\n ) AS subscribed_topics\n CROSS JOIN LATERAL (\n SELECT event_bus_events.*\n FROM event_bus_events\n WHERE event_bus_events.topic = subscribed_topics.topic\n AND event_bus_events.id > subscription.read_until\n AND NOT :id = ANY(event_bus_events.notified_subscribers)\n ORDER BY event_bus_events.id ASC\n LIMIT :limit\n ) AS event\n ORDER BY event.id ASC\n LIMIT :limit\n ),\n last_event_id AS (\n SELECT max(id) AS last_event_id\n FROM selected_events\n ),\n events_array AS (\n SELECT json_agg(row_to_json(selected_events) ORDER BY id) AS events\n FROM selected_events\n )\n UPDATE event_bus_subscriptions\n SET read_until = COALESCE(last_event_id, (SELECT MAX(id) FROM event_bus_events), 0)\n FROM events_array, last_event_id\n WHERE event_bus_subscriptions.id = :id\n RETURNING events_array.events\n `,\n { id, limit: MAX_BATCH_SIZE },\n );\n\n if (result.length === 0) {\n throw new NotFoundError(`Subscription with ID '${id}' not found`);\n } else if (result.length > 1) {\n throw new Error(\n `Failed to read subscription, unexpectedly updated ${result.length} rows`,\n );\n }\n\n const rows = result[0].events;\n if (!rows || rows.length === 0) {\n return { events: [] };\n }\n\n return {\n events: rows.map(row => {\n const { payload, metadata } = JSON.parse(row.data_json);\n return {\n topic: row.topic,\n eventPayload: payload,\n metadata,\n };\n }),\n };\n }\n\n async setupListener(\n subscriptionId: string,\n options: {\n signal: AbortSignal;\n },\n ): Promise<{ waitForUpdate(): Promise<{ topic: string }> }> {\n const result = await this.#db<SubscriptionsRow>(TABLE_SUBSCRIPTIONS)\n .select('topics')\n .where({ id: subscriptionId })\n .first();\n\n if (!result) {\n throw new NotFoundError(\n `Subscription with ID '${subscriptionId}' not found`,\n );\n }\n\n options.signal.throwIfAborted();\n\n return this.#listener.setupListener(\n new Set(result.topics ?? []),\n options.signal,\n );\n }\n\n async #cleanup(signal?: AbortSignal) {\n if (signal?.aborted) {\n return;\n }\n\n const stopAt = Date.now() + CLEANUP_RUN_DURATION_MS;\n let eventCount = 0;\n let subscriberCount = 0;\n\n const setStatementTimeout = async (trx: Knex.Transaction) => {\n const remainingMs = stopAt - Date.now();\n if (remainingMs <= 0) {\n return false;\n }\n\n await trx.raw(\n `SET LOCAL statement_timeout = '${Math.min(\n CLEANUP_STATEMENT_TIMEOUT_MS,\n remainingMs,\n )}ms'`,\n );\n return true;\n };\n\n const deleteEventBatch = (createdBefore: Date, maxId?: string) => {\n const bindings: (Date | string | number)[] = [createdBefore];\n if (maxId !== undefined) {\n bindings.push(maxId);\n }\n bindings.push(CLEANUP_BATCH_SIZE);\n\n return this.#db.transaction(async trx => {\n if (!(await setStatementTimeout(trx))) {\n return 0;\n }\n const result = await trx.raw<{ rowCount: number }>(\n `WITH candidates AS (\n SELECT id FROM event_bus_events\n WHERE created_at < ? ${maxId === undefined ? '' : 'AND id <= ?'}\n ORDER BY created_at, id\n LIMIT ? FOR UPDATE SKIP LOCKED\n )\n DELETE FROM event_bus_events\n WHERE id = ANY (ARRAY(SELECT id FROM candidates))`,\n bindings,\n );\n return result.rowCount;\n });\n };\n\n const deleteSubscriberBatch = () =>\n this.#db.transaction<number>(async trx => {\n if (!(await setStatementTimeout(trx))) {\n return 0;\n }\n const [{ min: minId }] = await trx(TABLE_EVENTS).min('id');\n\n if (!(await setStatementTimeout(trx))) {\n return 0;\n }\n\n const where =\n minId === null\n ? 'updated_at < ?'\n : 'read_until < CAST(? AS bigint) - 1';\n const binding =\n minId === null ? new Date(Date.now() - this.#windowMaxAge) : minId;\n const result = await trx.raw<{ rowCount: number }>(\n `WITH candidates AS (\n SELECT id FROM event_bus_subscriptions\n WHERE ${where}\n ORDER BY id\n LIMIT ? FOR UPDATE SKIP LOCKED\n )\n DELETE FROM event_bus_subscriptions\n WHERE id = ANY (ARRAY(SELECT id FROM candidates))`,\n [binding, CLEANUP_BATCH_SIZE],\n );\n return result.rowCount;\n });\n\n // Expire events past maxAge first, then trim older events past maxCount.\n let eventCleanupPhase: 'maxAge' | 'maxCount' | 'complete' = 'maxAge';\n let countCutoff: string | undefined;\n let failed = false;\n\n // Finish one bounded event batch and one bounded subscriber batch before\n // checking cancellation or the run budget, so neither can starve the other.\n while (!failed && !signal?.aborted && Date.now() < stopAt) {\n let subscriberDeleted = 0;\n\n try {\n if (eventCleanupPhase === 'maxAge') {\n const deleted = await deleteEventBatch(\n new Date(Date.now() - this.#windowMaxAge),\n );\n eventCount += deleted;\n if (deleted < CLEANUP_BATCH_SIZE) {\n eventCleanupPhase = 'maxCount';\n }\n } else if (eventCleanupPhase === 'maxCount') {\n if (countCutoff === undefined) {\n const cutoff = await this.#db.transaction(async trx => {\n if (!(await setStatementTimeout(trx))) {\n return undefined;\n }\n return trx<EventsRow>(TABLE_EVENTS)\n .select('id')\n .orderBy('id', 'desc')\n .offset(this.#windowMaxCount)\n .first();\n });\n countCutoff = cutoff?.id;\n }\n\n if (countCutoff === undefined) {\n eventCleanupPhase = 'complete';\n } else {\n const deleted = await deleteEventBatch(\n new Date(Date.now() - this.#windowMinAge),\n countCutoff,\n );\n eventCount += deleted;\n if (deleted < CLEANUP_BATCH_SIZE) {\n eventCleanupPhase = 'complete';\n }\n }\n }\n } catch (error) {\n this.#logger.error('Event cleanup failed', error);\n failed = true;\n }\n\n try {\n subscriberDeleted = await deleteSubscriberBatch();\n subscriberCount += subscriberDeleted;\n } catch (error) {\n this.#logger.error('Subscription cleanup failed', error);\n failed = true;\n }\n\n if (\n eventCleanupPhase === 'complete' &&\n subscriberDeleted < CLEANUP_BATCH_SIZE\n ) {\n break;\n }\n }\n\n if (eventCount > 0) {\n this.#logger.info(\n `Event cleanup resulted in ${eventCount} old events being deleted`,\n );\n }\n if (subscriberCount > 0) {\n this.#logger.info(\n `Subscription cleanup resulted in ${subscriberCount} stale subscribers being deleted`,\n );\n }\n }\n}\n"],"names":["resolvePackagePath","ForwardedError","durationToMilliseconds","NotFoundError"],"mappings":"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"}
@@ -0,0 +1,103 @@
1
+ /*
2
+ * Copyright 2026 The Backstage Authors
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // @ts-check
18
+
19
+ /**
20
+ * The event bus currently creates its tables only for PostgreSQL. Build these
21
+ * indexes concurrently so upgrading a busy event bus does not block writes.
22
+ * The old topic index is replaced by (topic, id), which also serves topic-only
23
+ * lookups while letting subscription reads start at their cursor.
24
+ *
25
+ * This migration runs outside a transaction because PostgreSQL forbids
26
+ * CREATE/DROP INDEX CONCURRENTLY inside one. An interrupted concurrent build
27
+ * can leave an invalid index, so retries repair it before continuing.
28
+ */
29
+
30
+ /**
31
+ * @param {import('knex').Knex} knex
32
+ * @param {string} name
33
+ * @param {string[]} columns
34
+ */
35
+ async function ensureIndex(knex, name, columns) {
36
+ const { rows } = await knex.raw(
37
+ `SELECT i.indisvalid, i.indisunique,
38
+ pg_get_indexdef(i.indexrelid, 1, true) AS first_column,
39
+ pg_get_indexdef(i.indexrelid, 2, true) AS second_column
40
+ FROM pg_class index_table
41
+ JOIN pg_namespace namespace ON namespace.oid = index_table.relnamespace
42
+ JOIN pg_index i ON i.indexrelid = index_table.oid
43
+ JOIN pg_class event_table ON event_table.oid = i.indrelid
44
+ WHERE index_table.relname = ?
45
+ AND namespace.nspname = current_schema()
46
+ AND event_table.relname = 'event_bus_events'`,
47
+ [name],
48
+ );
49
+
50
+ if (rows.length > 0) {
51
+ const index = rows[0];
52
+ if (index.indisvalid) {
53
+ const actualColumns = [index.first_column, index.second_column].filter(
54
+ Boolean,
55
+ );
56
+ if (index.indisunique || actualColumns.join(',') !== columns.join(',')) {
57
+ throw new Error(`Existing index ${name} has an unexpected definition`);
58
+ }
59
+ return;
60
+ }
61
+
62
+ await knex.raw(`DROP INDEX CONCURRENTLY IF EXISTS ${name}`);
63
+ }
64
+
65
+ await knex.raw(
66
+ `CREATE INDEX CONCURRENTLY ${name} ON event_bus_events (${columns.join(
67
+ ', ',
68
+ )})`,
69
+ );
70
+ }
71
+
72
+ /** @param {import('knex').Knex} knex */
73
+ exports.up = async function up(knex) {
74
+ if (knex.client.config.client !== 'pg') {
75
+ return;
76
+ }
77
+
78
+ await ensureIndex(knex, 'event_bus_events_created_at_id_idx', [
79
+ 'created_at',
80
+ 'id',
81
+ ]);
82
+ await ensureIndex(knex, 'event_bus_events_topic_id_idx', ['topic', 'id']);
83
+ await knex.raw(
84
+ 'DROP INDEX CONCURRENTLY IF EXISTS event_bus_events_topic_idx',
85
+ );
86
+ };
87
+
88
+ /** @param {import('knex').Knex} knex */
89
+ exports.down = async function down(knex) {
90
+ if (knex.client.config.client !== 'pg') {
91
+ return;
92
+ }
93
+
94
+ await ensureIndex(knex, 'event_bus_events_topic_idx', ['topic']);
95
+ await knex.raw(
96
+ 'DROP INDEX CONCURRENTLY IF EXISTS event_bus_events_topic_id_idx',
97
+ );
98
+ await knex.raw(
99
+ 'DROP INDEX CONCURRENTLY IF EXISTS event_bus_events_created_at_id_idx',
100
+ );
101
+ };
102
+
103
+ exports.config = { transaction: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backstage/plugin-events-backend",
3
- "version": "0.6.7-next.1",
3
+ "version": "0.6.7-next.2",
4
4
  "backstage": {
5
5
  "role": "backend-plugin",
6
6
  "pluginId": "events",
@@ -67,7 +67,7 @@
67
67
  "test": "backstage-cli package test"
68
68
  },
69
69
  "dependencies": {
70
- "@backstage/backend-openapi-utils": "0.7.3-next.1",
70
+ "@backstage/backend-openapi-utils": "0.7.3-next.2",
71
71
  "@backstage/backend-plugin-api": "1.11.0-next.1",
72
72
  "@backstage/config": "1.3.10-next.0",
73
73
  "@backstage/errors": "1.3.2-next.0",
@@ -81,11 +81,11 @@
81
81
  },
82
82
  "devDependencies": {
83
83
  "@backstage/backend-app-api": "1.8.0-next.1",
84
- "@backstage/backend-defaults": "0.18.1-next.1",
84
+ "@backstage/backend-defaults": "0.18.1-next.2",
85
85
  "@backstage/backend-test-utils": "1.11.8-next.1",
86
86
  "@backstage/cli": "0.36.7-next.0",
87
87
  "@backstage/plugin-events-backend-test-utils": "0.1.60-next.0",
88
- "@backstage/repo-tools": "0.19.2-next.1",
88
+ "@backstage/repo-tools": "0.19.2-next.2",
89
89
  "@types/content-type": "^1.1.8",
90
90
  "supertest": "^7.0.0"
91
91
  },