fix(book-requests): tell a plugin indexer when its connection changes

Add an optional forget(indexerId) to the plugin contract and forward the
host's existing invalidation to it, so a plugin holding a live session
drops it when the credential, base URL or private-address setting changes.
A plugin that throws or rejects in forget is logged and never fails the save.

Fixes #1325
This commit is contained in:
neon
2026-10-20 16:51:24 +00:00
parent b95a285c1b
commit cc6bac696b
4 changed files with 102 additions and 0 deletions
+7
View File
@@ -252,4 +252,11 @@ export interface IndexerPlugin {
resolveFile?(release: PluginReleaseCandidate, config: PluginIndexerConfig, host: PluginHost, signal: AbortSignal): Promise<PluginReleaseFile>; resolveFile?(release: PluginReleaseCandidate, config: PluginIndexerConfig, host: PluginHost, signal: AbortSignal): Promise<PluginReleaseFile>;
/** Sources whose session expires on its own clock, called on a schedule while enabled. */ /** Sources whose session expires on its own clock, called on a schedule while enabled. */
keepalive?(config: PluginIndexerConfig, host: PluginHost): Promise<void>; keepalive?(config: PluginIndexerConfig, host: PluginHost): Promise<void>;
/**
* Drop anything held in memory for this indexer, such as a live session. Called when an operator
* changes its base URL, credential or private-address setting, so the next call starts from the
* stored config. Not awaited, and a failure is logged rather than reported. Never called by hosts
* older than the hook.
*/
forget?(indexerId: number): void | Promise<void>;
} }
@@ -3,6 +3,7 @@ import { DEFAULT_INDEXER_CATEGORIES, INDEXER_COLORS } from '@bookorbit/types';
import type { RequestIndexerManagerRow, RequestIndexerRow } from '../../../db/schema'; import type { RequestIndexerManagerRow, RequestIndexerRow } from '../../../db/schema';
import { IndexerConfigService } from './indexer-config.service'; import { IndexerConfigService } from './indexer-config.service';
import { PluginIndexerAdapter } from './plugins/plugin-host';
function indexerRow(overrides: Partial<RequestIndexerRow> = {}): RequestIndexerRow { function indexerRow(overrides: Partial<RequestIndexerRow> = {}): RequestIndexerRow {
return { return {
@@ -493,6 +494,41 @@ describe('IndexerConfigService', () => {
expect(adapter.forget).not.toHaveBeenCalled(); expect(adapter.forget).not.toHaveBeenCalled();
}); });
/** A plugin's in-memory session otherwise outranks the stored credential until the process restarts. */
it('drops a plugin-backed session when its connection details change', async () => {
const forget = vi.fn();
const adapter = new PluginIndexerAdapter(
{
apiVersion: 1,
type: 'demo-tracker',
label: 'Demo Tracker',
requiresCredential: true,
credentialKind: 'sessionId',
mediaKinds: ['ebook'],
usesCategories: false,
seedsBack: true,
search: () => Promise.resolve([]),
test: () => Promise.resolve({ success: true }),
fetchTorrentFile: () => Promise.resolve(new Uint8Array()),
forget,
},
{} as never,
);
const { service } = makeService({
repo: { findById: vi.fn().mockResolvedValue(indexerRow({ adapterType: 'demo-tracker' })) },
registry: { require: vi.fn().mockReturnValue(adapter), find: vi.fn().mockReturnValue(adapter) },
});
await service.update(9, { credential: 'a-new-session' });
await service.update(9, { baseUrl: 'http://203.0.113.11:9117' });
await service.update(9, { allowPrivateAddress: true });
expect(forget.mock.calls).toEqual([[9], [9], [9]]);
forget.mockClear();
await service.update(9, { name: 'renamed', enabled: false });
expect(forget).not.toHaveBeenCalled();
});
it('falls back to the adapter default categories for a medium left blank', async () => { it('falls back to the adapter default categories for a medium left blank', async () => {
const { service, repo } = makeService(); const { service, repo } = makeService();
@@ -18,6 +18,7 @@ vi.mock('dns/promises', async (importOriginal) => ({
})); }));
import { lookup } from 'dns/promises'; import { lookup } from 'dns/promises';
import { Logger } from '@nestjs/common';
import type { IndexerCredentialStore } from '../indexer-credential-store'; import type { IndexerCredentialStore } from '../indexer-credential-store';
import { IndexerSearchException, type ReleaseQuery, type ResolvedIndexerConfig } from '../indexer-adapter'; import { IndexerSearchException, type ReleaseQuery, type ResolvedIndexerConfig } from '../indexer-adapter';
@@ -616,4 +617,42 @@ describe('PluginIndexerAdapter', () => {
expect(credentials.rotate).toHaveBeenNthCalledWith(1, 4, 'session-2', 'session-1'); expect(credentials.rotate).toHaveBeenNthCalledWith(1, 4, 'session-2', 'session-1');
expect(credentials.rotate).toHaveBeenNthCalledWith(2, 4, 'session-3', 'session-2'); expect(credentials.rotate).toHaveBeenNthCalledWith(2, 4, 'session-3', 'session-2');
}); });
describe('forget', () => {
/** A plugin holding a live session would otherwise keep using it until the process restarts. */
it('tells the plugin to drop what it holds for the indexer', () => {
const forget = vi.fn();
const { adapter } = makeAdapter({ forget });
adapter.forget(12);
expect(forget).toHaveBeenCalledWith(12);
});
it('does nothing for a plugin written before the hook', () => {
expect(() => makeAdapter().adapter.forget(12)).not.toThrow();
expect(() => makeAdapter({ forget: true as never }).adapter.forget(12)).not.toThrow();
});
/** The operator's change is already saved by then, so a broken plugin cannot be allowed to fail it. */
it('logs a plugin that throws or rejects instead of failing the save', async () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
expect(() =>
makeAdapter({
forget: () => {
throw new Error('no session map');
},
}).adapter.forget(12),
).not.toThrow();
makeAdapter({ forget: () => Promise.reject(new Error('cache closed')) }).adapter.forget(13);
await new Promise((resolve) => setImmediate(resolve));
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/indexerId=12 .*no session map/));
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/indexerId=13 .*cache closed/));
} finally {
warn.mockRestore();
}
});
});
}); });
@@ -152,6 +152,26 @@ export class PluginIndexerAdapter implements IndexerAdapter {
}; };
} }
/**
* Runs after the operator's change has already been saved, so nothing a plugin does here may fail
* that save: a throw or a rejection is logged and dropped.
*/
forget(indexerId: number): void {
if (typeof this.plugin.forget !== 'function') return;
try {
void Promise.resolve(this.plugin.forget(indexerId)).catch((error: unknown) => this.warnForgetFailed(indexerId, error));
} catch (error) {
this.warnForgetFailed(indexerId, error);
}
}
private warnForgetFailed(indexerId: number, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(
`[request_indexer.plugin] [fail] indexerId=${indexerId} type=${this.type} error="${sanitizeLogValue(message)}" - the plugin threw while forgetting its session`,
);
}
/** /**
* `deadline` is the caller's, where the caller has one. It is applied here rather than handed to * `deadline` is the caller's, where the caller has one. It is applied here rather than handed to
* the plugin because a plugin can only check a signal between requests, and the request that * the plugin because a plugin can only check a signal between requests, and the request that