events-node: more resilient and configurable events service
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -16,12 +16,16 @@
|
||||
|
||||
import { DefaultEventsService } from './DefaultEventsService';
|
||||
import { EventParams } from './EventParams';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
|
||||
const logger = mockServices.logger.mock();
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import {
|
||||
mockServices,
|
||||
registerMswTestHooks,
|
||||
} from '@backstage/backend-test-utils';
|
||||
|
||||
describe('DefaultEventsService', () => {
|
||||
it('passes events to interested subscribers', async () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const events = DefaultEventsService.create({ logger });
|
||||
const eventsSubscriber1: EventParams[] = [];
|
||||
const eventsSubscriber2: EventParams[] = [];
|
||||
@@ -70,6 +74,7 @@ describe('DefaultEventsService', () => {
|
||||
it('logs errors from subscribers', async () => {
|
||||
const topic = 'testTopic';
|
||||
|
||||
const logger = mockServices.logger.mock();
|
||||
const warnSpy = jest.spyOn(logger, 'warn');
|
||||
const events = DefaultEventsService.create({ logger });
|
||||
|
||||
@@ -108,4 +113,165 @@ describe('DefaultEventsService', () => {
|
||||
new Error('NOPE 2'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('with event bus', () => {
|
||||
const mswServer = setupServer();
|
||||
registerMswTestHooks(mswServer);
|
||||
|
||||
it('should read events from events bus API', async () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const service = DefaultEventsService.create({ logger }).forPlugin('a', {
|
||||
auth: mockServices.auth(),
|
||||
logger,
|
||||
discovery: mockServices.discovery(),
|
||||
lifecycle: mockServices.lifecycle.mock(),
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
rest.put(
|
||||
'http://localhost:0/api/events/bus/v1/subscriptions/a.tester',
|
||||
(_req, res, ctx) => res(ctx.status(200)),
|
||||
),
|
||||
rest.get(
|
||||
'http://localhost:0/api/events/bus/v1/subscriptions/a.tester/events',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
events: [{ topic: 'test', payload: { foo: 'bar' } }],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const event = await new Promise(resolve => {
|
||||
service.subscribe({
|
||||
id: 'tester',
|
||||
topics: ['test'],
|
||||
async onEvent(newEvent) {
|
||||
resolve(newEvent);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(event).toEqual({ topic: 'test', eventPayload: { foo: 'bar' } });
|
||||
|
||||
// Internal call to clean up subscriptions
|
||||
await (service as any).shutdown();
|
||||
});
|
||||
|
||||
it('should not read events from bus if disabled', async () => {
|
||||
const logger = mockServices.logger.mock();
|
||||
const service = DefaultEventsService.create({
|
||||
logger,
|
||||
useEventBus: 'never',
|
||||
}).forPlugin('a', {
|
||||
auth: mockServices.auth(),
|
||||
logger,
|
||||
discovery: mockServices.discovery(),
|
||||
lifecycle: mockServices.lifecycle.mock(),
|
||||
});
|
||||
|
||||
let calledApi = false;
|
||||
mswServer.use(
|
||||
rest.put(
|
||||
'http://localhost:0/api/events/bus/v1/subscriptions/a.tester',
|
||||
(_req, res, ctx) => {
|
||||
calledApi = true;
|
||||
res(ctx.status(200));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await service.subscribe({
|
||||
id: 'tester',
|
||||
topics: ['test'],
|
||||
async onEvent() {
|
||||
expect('not').toBe('reached');
|
||||
},
|
||||
});
|
||||
|
||||
// Internal call to clean up subscriptions, and wait for the API call
|
||||
await (service as any).shutdown();
|
||||
|
||||
expect(calledApi).toBe(false);
|
||||
});
|
||||
|
||||
it('should deactivate event bus on 404', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const logger = mockServices.logger.mock();
|
||||
const service = DefaultEventsService.create({ logger }).forPlugin('a', {
|
||||
auth: mockServices.auth(),
|
||||
logger,
|
||||
discovery: mockServices.discovery(),
|
||||
lifecycle: mockServices.lifecycle.mock(),
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
rest.put(
|
||||
'http://localhost:0/api/events/bus/v1/subscriptions/a.tester',
|
||||
(_req, res, ctx) => res(ctx.status(404)),
|
||||
),
|
||||
);
|
||||
|
||||
service.subscribe({
|
||||
id: 'tester',
|
||||
topics: ['test'],
|
||||
async onEvent() {
|
||||
expect('not').toBe('reached');
|
||||
},
|
||||
});
|
||||
|
||||
const msg = await new Promise(resolve => {
|
||||
logger.warn.mockImplementationOnce(resolve);
|
||||
});
|
||||
|
||||
expect(msg).toMatch(/Event subscribe request failed with status 404/);
|
||||
|
||||
// Internal call to clean up subscriptions
|
||||
await (service as any).shutdown();
|
||||
});
|
||||
|
||||
it('should not deactivate event bus if configured to always be used', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
const logger = mockServices.logger.mock();
|
||||
const service = DefaultEventsService.create({
|
||||
logger,
|
||||
useEventBus: 'always',
|
||||
}).forPlugin('a', {
|
||||
auth: mockServices.auth(),
|
||||
logger,
|
||||
discovery: mockServices.discovery(),
|
||||
lifecycle: mockServices.lifecycle.mock(),
|
||||
});
|
||||
|
||||
mswServer.use(
|
||||
rest.put(
|
||||
'http://localhost:0/api/events/bus/v1/subscriptions/a.tester',
|
||||
(_req, res, ctx) => res(ctx.status(404)),
|
||||
),
|
||||
);
|
||||
|
||||
service.subscribe({
|
||||
id: 'tester',
|
||||
topics: ['test'],
|
||||
async onEvent() {
|
||||
expect('not').toBe('reached');
|
||||
},
|
||||
});
|
||||
|
||||
const msg = await new Promise(resolve => {
|
||||
logger.warn.mockImplementationOnce(resolve);
|
||||
});
|
||||
|
||||
expect(msg).toMatch(
|
||||
'Poll failed for subscription "a.tester", retrying in 1000ms',
|
||||
);
|
||||
|
||||
// Internal call to clean up subscriptions
|
||||
await (service as any).shutdown();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DiscoveryService,
|
||||
LifecycleService,
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { EventParams } from './EventParams';
|
||||
import { EventsService, EventsServiceSubscribeOptions } from './EventsService';
|
||||
@@ -29,6 +30,13 @@ const POLL_BACKOFF_START_MS = 1_000;
|
||||
const POLL_BACKOFF_MAX_MS = 60_000;
|
||||
const POLL_BACKOFF_FACTOR = 2;
|
||||
|
||||
const EVENT_BUS_MODES = ['never', 'always', 'auto'] as const;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type EventBusMode = 'never' | 'always' | 'auto';
|
||||
|
||||
/**
|
||||
* Local event bus for subscribers within the same process.
|
||||
*
|
||||
@@ -107,23 +115,28 @@ class PluginEventsService implements EventsService {
|
||||
private readonly pluginId: string,
|
||||
private readonly localBus: LocalEventBus,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly mode: EventBusMode,
|
||||
private client?: DefaultApiClient,
|
||||
private readonly auth?: AuthService,
|
||||
) {}
|
||||
|
||||
async publish(params: EventParams): Promise<void> {
|
||||
const lock = this.#getShutdownLock();
|
||||
if (!lock) {
|
||||
throw new Error('Service is shutting down');
|
||||
}
|
||||
try {
|
||||
const { notifiedSubscribers } = await this.localBus.publish(params);
|
||||
|
||||
if (!this.client) {
|
||||
const client = this.client;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const token = await this.#getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const res = await this.client.postEvent(
|
||||
const res = await client.postEvent(
|
||||
{
|
||||
body: {
|
||||
event: { payload: params.eventPayload, topic: params.topic },
|
||||
@@ -134,7 +147,7 @@ class PluginEventsService implements EventsService {
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
if (res.status === 404 && this.mode !== 'always') {
|
||||
this.logger.warn(
|
||||
`Event publish request failed with status 404, events backend not found. Future events will not be persisted.`,
|
||||
);
|
||||
@@ -160,27 +173,6 @@ class PluginEventsService implements EventsService {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
const token = await this.#getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const res = await this.client.putSubscription(
|
||||
{
|
||||
path: { subscriptionId },
|
||||
body: { topics: options.topics },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
this.logger.warn(
|
||||
`Event subscribe request failed with status 404, events backend not found. Will only receive events that were sent locally on this process.`,
|
||||
);
|
||||
delete this.client;
|
||||
return;
|
||||
}
|
||||
throw await ResponseError.fromResponse(res);
|
||||
}
|
||||
|
||||
this.#startPolling(subscriptionId, options.topics, options.onEvent);
|
||||
}
|
||||
@@ -190,75 +182,101 @@ class PluginEventsService implements EventsService {
|
||||
topics: string[],
|
||||
onEvent: EventsServiceSubscribeOptions['onEvent'],
|
||||
) {
|
||||
let hasSubscription = false;
|
||||
let backoffMs = POLL_BACKOFF_START_MS;
|
||||
const poll = async () => {
|
||||
if (!this.client) {
|
||||
const client = this.client;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const lock = this.#getShutdownLock();
|
||||
if (!lock) {
|
||||
return; // shutting down
|
||||
}
|
||||
try {
|
||||
const token = await this.#getToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
const res = await this.client.getSubscriptionEvents(
|
||||
{
|
||||
path: { subscriptionId },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
this.logger.info(
|
||||
`Polling event subscription resulted in a 404, recreating subscription`,
|
||||
);
|
||||
const putRes = await this.client.putSubscription(
|
||||
{
|
||||
path: { subscriptionId },
|
||||
body: { topics },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
if (!putRes.ok) {
|
||||
if (hasSubscription) {
|
||||
const res = await client.getSubscriptionEvents(
|
||||
{
|
||||
path: { subscriptionId },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
this.logger.info(
|
||||
`Polling event subscription resulted in a 404, recreating subscription`,
|
||||
);
|
||||
hasSubscription = false;
|
||||
} else {
|
||||
throw await ResponseError.fromResponse(res);
|
||||
}
|
||||
}
|
||||
throw await ResponseError.fromResponse(res);
|
||||
}
|
||||
backoffMs = POLL_BACKOFF_START_MS;
|
||||
|
||||
// 202 means there were no immediately available events, but the
|
||||
// response will block until either new events are available or the
|
||||
// request times out. In both cases we should should try to read events
|
||||
// immediately again
|
||||
if (res.status === 202) {
|
||||
lock.release();
|
||||
await res.body?.getReader()?.closed;
|
||||
process.nextTick(poll);
|
||||
} else if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
if (data) {
|
||||
for (const event of data.events ?? []) {
|
||||
try {
|
||||
await onEvent({
|
||||
topic: event.topic,
|
||||
eventPayload: event.payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Subscriber "${subscriptionId}" failed to process event for topic "${event.topic}"`,
|
||||
error,
|
||||
);
|
||||
// Successful response, reset backoff
|
||||
backoffMs = POLL_BACKOFF_START_MS;
|
||||
|
||||
// 202 means there were no immediately available events, but the
|
||||
// response will block until either new events are available or the
|
||||
// request times out. In both cases we should should try to read events
|
||||
// immediately again
|
||||
if (res.status === 202) {
|
||||
lock.release();
|
||||
await res.body?.getReader()?.closed;
|
||||
process.nextTick(poll);
|
||||
} else if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
if (data) {
|
||||
for (const event of data.events ?? []) {
|
||||
try {
|
||||
await onEvent({
|
||||
topic: event.topic,
|
||||
eventPayload: event.payload,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Subscriber "${subscriptionId}" failed to process event for topic "${event.topic}"`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Unexpected response status ${res.status} from events backend for subscription "${subscriptionId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
process.nextTick(poll);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Unexpected response status ${res.status} from events backend for subscription "${subscriptionId}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// If we haven't yet created the subscription, or if it was removed, create a new one
|
||||
if (!hasSubscription) {
|
||||
const res = await client.putSubscription(
|
||||
{
|
||||
path: { subscriptionId },
|
||||
body: { topics },
|
||||
},
|
||||
{ token },
|
||||
);
|
||||
hasSubscription = true;
|
||||
if (!res.ok) {
|
||||
if (res.status === 404 && this.mode !== 'always') {
|
||||
this.logger.warn(
|
||||
`Event subscribe request failed with status 404, events backend not found. Will only receive events that were sent locally on this process.`,
|
||||
);
|
||||
// Events backend is not present and not configured to always be used, bail out and stop polling
|
||||
delete this.client;
|
||||
return;
|
||||
}
|
||||
throw await ResponseError.fromResponse(res);
|
||||
}
|
||||
}
|
||||
|
||||
process.nextTick(poll);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Poll failed for subscription "${subscriptionId}", retrying in ${backoffMs.toFixed(
|
||||
@@ -292,7 +310,10 @@ class PluginEventsService implements EventsService {
|
||||
} catch (error) {
|
||||
// This is a bit hacky, but handles the case where new auth is used
|
||||
// without legacy auth fallback, and the events backend is not installed
|
||||
if (String(error).includes('Unable to generate legacy token')) {
|
||||
if (
|
||||
String(error).includes('Unable to generate legacy token') &&
|
||||
this.mode !== 'always'
|
||||
) {
|
||||
this.logger.warn(
|
||||
`The events backend is not available and neither is legacy auth. Future events will not be persisted.`,
|
||||
);
|
||||
@@ -309,22 +330,25 @@ class PluginEventsService implements EventsService {
|
||||
}
|
||||
|
||||
#isShuttingDown = false;
|
||||
#shutdownLocks: Promise<void>[] = [];
|
||||
#shutdownLocks = new Set<Promise<void>>();
|
||||
|
||||
// This locking mechanism helps ensure that we are either idle or waiting for
|
||||
// a blocked events call before shutting down. It increases out changes of
|
||||
// never dropping any events on shutdown.
|
||||
#getShutdownLock(): { release(): void } {
|
||||
#getShutdownLock(): { release(): void } | undefined {
|
||||
if (this.#isShuttingDown) {
|
||||
throw new Error('Service is shutting down');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let release: () => void;
|
||||
this.#shutdownLocks.push(
|
||||
new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const lock = new Promise<void>(resolve => {
|
||||
release = () => {
|
||||
resolve();
|
||||
this.#shutdownLocks.delete(lock);
|
||||
};
|
||||
});
|
||||
this.#shutdownLocks.add(lock);
|
||||
return { release: release! };
|
||||
}
|
||||
}
|
||||
@@ -342,12 +366,30 @@ export class DefaultEventsService implements EventsService {
|
||||
private constructor(
|
||||
private readonly logger: LoggerService,
|
||||
private readonly localBus: LocalEventBus,
|
||||
private readonly mode: EventBusMode,
|
||||
) {}
|
||||
|
||||
static create(options: { logger: LoggerService }): DefaultEventsService {
|
||||
static create(options: {
|
||||
logger: LoggerService;
|
||||
config?: RootConfigService;
|
||||
useEventBus?: EventBusMode;
|
||||
}): DefaultEventsService {
|
||||
const eventBusMode =
|
||||
options.useEventBus ??
|
||||
((options.config?.getOptionalString('events.useEventBus') ??
|
||||
'auto') as EventBusMode);
|
||||
if (!EVENT_BUS_MODES.includes(eventBusMode)) {
|
||||
throw new Error(
|
||||
`Invalid events.useEventBus config, must be one of ${EVENT_BUS_MODES.join(
|
||||
', ',
|
||||
)}, got '${eventBusMode}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return new DefaultEventsService(
|
||||
options.logger,
|
||||
new LocalEventBus(options.logger),
|
||||
eventBusMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -367,16 +409,18 @@ export class DefaultEventsService implements EventsService {
|
||||
},
|
||||
): EventsService {
|
||||
const client =
|
||||
options &&
|
||||
new DefaultApiClient({
|
||||
discoveryApi: options.discovery,
|
||||
fetchApi: { fetch }, // use native node fetch
|
||||
});
|
||||
options && this.mode !== 'never'
|
||||
? new DefaultApiClient({
|
||||
discoveryApi: options.discovery,
|
||||
fetchApi: { fetch }, // use native node fetch
|
||||
})
|
||||
: undefined;
|
||||
const logger = options?.logger ?? this.logger;
|
||||
const service = new PluginEventsService(
|
||||
pluginId,
|
||||
this.localBus,
|
||||
logger,
|
||||
this.mode,
|
||||
client,
|
||||
options?.auth,
|
||||
);
|
||||
|
||||
@@ -21,6 +21,9 @@ export type {
|
||||
EventsServiceSubscribeOptions,
|
||||
EventsServiceEventHandler,
|
||||
} from './EventsService';
|
||||
export { DefaultEventsService } from './DefaultEventsService';
|
||||
export {
|
||||
DefaultEventsService,
|
||||
type EventBusMode,
|
||||
} from './DefaultEventsService';
|
||||
export * from './http';
|
||||
export { SubTopicEventRouter } from './SubTopicEventRouter';
|
||||
|
||||
Reference in New Issue
Block a user