mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user