events-backend: refactor event bus notifications and add timeout

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-05-24 15:33:33 +02:00
parent ab035ebc1f
commit 2d25e2791c
4 changed files with 72 additions and 30 deletions
@@ -378,27 +378,37 @@ export class DatabaseEventBusStore implements EventBusStore {
async listen(
subscriptionId: string,
listeners: {
options: {
signal: AbortSignal;
onNotify: (topicId: string) => void;
onError: () => void;
},
): Promise<{ cancel(): void }> {
): Promise<void> {
const result = await this.#db<SubscriptionsRow>(TABLE_SUBSCRIPTIONS)
.select('topics')
.where({ id: subscriptionId })
.first();
console.log(`DEBUG: result=`, result);
if (!result) {
throw new NotFoundError(
`Subscription with ID '${subscriptionId}' not found`,
);
}
if (options.signal.aborted) {
return;
}
const topics = new Set(result.topics ?? []);
const cancel = await this.#listener.listen(
topics,
listeners.onNotify,
listeners.onError,
options.onNotify,
options.onError,
);
return { cancel };
if (options.signal.aborted) {
cancel();
} else {
options.signal.addEventListener('abort', cancel);
}
}
}
@@ -99,21 +99,25 @@ export class MemoryEventBusStore implements EventBusStore {
async listen(
subscriptionId: string,
listeners: {
options: {
signal: AbortSignal;
onNotify(topicId: string): void;
onError(): void;
},
): Promise<{ cancel(): void }> {
): Promise<void> {
if (options.signal.aborted) {
return;
}
const sub = this.#subscribers.get(subscriptionId);
if (!sub) {
throw new Error(`Subscription not found`);
}
const listener = { topics: sub.topics, notify: listeners.onNotify };
const listener = { topics: sub.topics, notify: options.onNotify };
this.#listeners.add(listener);
return {
cancel: () => {
this.#listeners.delete(listener);
},
};
options.signal.addEventListener('abort', () => {
this.#listeners.delete(listener);
});
}
}
@@ -27,12 +27,19 @@ import { DatabaseEventBusStore } from './DatabaseEventBusStore';
import { EventBusStore } from './types';
import { EventParams } from '@backstage/plugin-events-node';
const DEFAULT_NOTIFY_TIMEOUT_MS = 55_000; // Just below 60s, which is a common HTTP timeout
export async function createEventBusRouter(options: {
logger: LoggerService;
database: DatabaseService;
httpAuth: HttpAuthService;
notifyTimeoutMs?: number; // for testing
}): Promise<Handler> {
const { database, httpAuth } = options;
const {
database,
httpAuth,
notifyTimeoutMs = DEFAULT_NOTIFY_TIMEOUT_MS,
} = options;
const logger = options.logger.child({ type: 'EventBus' });
const router = Router();
@@ -92,28 +99,48 @@ export async function createEventBusRouter(options: {
});
const id = req.params.subscriptionId;
// Don't notify until we know the outcome of reading events
let resolveShouldNotify: (shouldNotify: boolean) => void;
const shouldNotifyPromise = new Promise<boolean>(resolve => {
resolveShouldNotify = resolve;
});
const { cancel } = await store.listen(id, {
const controller = new AbortController();
req.on('end', () => controller.abort());
let timeout: NodeJS.Timeout | undefined = undefined;
let notified = false;
const notify = async (status: number) => {
if (!notified) {
clearTimeout(timeout);
notified = true;
if (await shouldNotifyPromise) {
res.status(status).end();
}
}
};
// By setting up the listener first we make sure we don't miss any events
// that are published while reading. If an event is published we'll receive
// a notification, which depending on the outcome of the read we may ignore
await store.listen(id, {
signal: controller.signal,
onNotify() {
shouldNotifyPromise.then(shouldNotify => {
if (shouldNotify) {
res.status(204).end();
}
});
notify(204);
},
onError() {
shouldNotifyPromise.then(shouldNotify => {
if (shouldNotify) {
res.status(500).end();
}
});
notify(500);
},
});
req.on('end', cancel);
// By timing out requests we make sure they don't stall or that events get stuck.
// For the caller there's no difference between a timeout and a
// notifications, either way they should try reading again.
timeout = setTimeout(() => {
notify(204);
controller.abort();
}, notifyTimeoutMs);
try {
const { events } = await store.readSubscription(id);
@@ -125,11 +152,11 @@ export async function createEventBusRouter(options: {
if (events.length > 0) {
res.json({ events });
resolveShouldNotify!(false);
} else {
resolveShouldNotify!(true);
}
} finally {
clearTimeout(timeout);
resolveShouldNotify!(false);
}
},
@@ -28,9 +28,10 @@ export type EventBusStore = {
listen(
subscriptionId: string,
listeners: {
options: {
signal: AbortSignal;
onNotify(topicId: string): void;
onError(): void;
},
): Promise<{ cancel(): void }>;
): Promise<void>;
};