Skip to content

Commit 8de2296

Browse files
authoredDec 16, 2024··
Merge pull request #221 from FoxxMD/improveInitUsage
refactor: bubble init usage and errors to simplify recovery
2 parents c1869f4 + 3e230b0 commit 8de2296

25 files changed

+392
-194
lines changed
 

‎src/backend/common/AbstractComponent.ts

+65-18
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
import deepEqual from 'fast-deep-equal';
66
import { Simulate } from "react-dom/test-utils";
77
import { PlayObject } from "../../core/Atomic.js";
8-
import { buildTrackString } from "../../core/StringUtils.js";
8+
import { buildTrackString, truncateStringToLength } from "../../core/StringUtils.js";
99

1010
import {
1111
configPartsToStrongParts, countRegexes,
@@ -23,6 +23,9 @@ import {
2323
import { CommonClientConfig } from "./infrastructure/config/client/index.js";
2424
import { CommonSourceConfig } from "./infrastructure/config/source/index.js";
2525
import play = Simulate.play;
26+
import { WebhookPayload } from "./infrastructure/config/health/webhooks.js";
27+
import { AuthCheckError, BuildDataError, ConnectionCheckError, PostInitError, TransformRulesError } from "./errors/MSErrors.js";
28+
import { messageWithCauses, messageWithCausesTruncatedDefault } from "../utils/ErrorUtils.js";
2629

2730
export default abstract class AbstractComponent {
2831
requiresAuth: boolean = false;
@@ -47,27 +50,36 @@ export default abstract class AbstractComponent {
4750
this.config = config;
4851
}
4952

50-
initialize = async () => {
53+
public abstract notify(payload: WebhookPayload): Promise<void>;
54+
55+
protected abstract getIdentifier(): string;
56+
57+
initialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
58+
59+
const {force = false, notify = false, notifyTitle = 'Init Error'} = options;
60+
5161
this.logger.debug('Attempting to initialize...');
5262
try {
5363
this.initializing = true;
5464
if(this.componentLogger === undefined) {
5565
await this.buildComponentLogger();
5666
}
57-
await this.buildInitData();
67+
await this.buildInitData(force);
5868
this.buildTransformRules();
59-
await this.checkConnection();
60-
await this.testAuth();
69+
await this.checkConnection(force);
70+
await this.testAuth(force);
6171
this.logger.info('Fully Initialized!');
6272
try {
6373
await this.postInitialize();
6474
} catch (e) {
65-
this.logger.warn(new Error('Error occurred during post-initialization hook but was caught', {cause: e}));
75+
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
6676
}
6777
return true;
6878
} catch(e) {
69-
this.logger.error(new Error('Initialization failed', {cause: e}));
70-
return false;
79+
if(notify) {
80+
await this.notify({title: `${this.getIdentifier()} - ${notifyTitle}`, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
81+
}
82+
throw new Error('Initialization failed', {cause: e});
7183
} finally {
7284
this.initializing = false;
7385
}
@@ -82,9 +94,23 @@ export default abstract class AbstractComponent {
8294
return;
8395
}
8496

85-
public async buildInitData() {
97+
tryInitialize = async (options: {force?: boolean, notify?: boolean, notifyTitle?: string} = {}) => {
98+
if(this.initializing) {
99+
throw new Error(`Already trying to initialize, cannot attempt while an existing initialization attempt is running.`)
100+
}
101+
try {
102+
return await this.initialize(options);
103+
} catch (e) {
104+
throw e;
105+
}
106+
}
107+
108+
public async buildInitData(force: boolean = false) {
86109
if(this.buildOK) {
87-
return;
110+
if(!force) {
111+
return;
112+
}
113+
this.logger.debug('Build OK but step was forced');
88114
}
89115
try {
90116
const res = await this.doBuildInitData();
@@ -101,7 +127,7 @@ export default abstract class AbstractComponent {
101127
this.buildOK = true;
102128
} catch (e) {
103129
this.buildOK = false;
104-
throw new Error('Building required data for initialization failed', {cause: e});
130+
throw new BuildDataError('Building required data for initialization failed', {cause: e});
105131
}
106132
}
107133

@@ -122,13 +148,13 @@ export default abstract class AbstractComponent {
122148
this.doBuildTransformRules();
123149
} catch (e) {
124150
this.buildOK = false;
125-
throw new Error('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
151+
throw new TransformRulesError('Could not build playTransform rules. Check your configuration is valid.', {cause: e});
126152
}
127153
try {
128154
const ruleCount = countRegexes(this.transformRules);
129155
this.regexCache = cacheFunctions(ruleCount);
130156
} catch (e) {
131-
this.logger.warn(new Error('Failed to count number of rule regexes for caching but will continue will fallback to 100', {cause: e}));
157+
this.logger.warn(new TransformRulesError('Failed to count number of rule regexes for caching but will continue will fallback to 100', {cause: e}));
132158
}
133159
}
134160

@@ -192,7 +218,13 @@ export default abstract class AbstractComponent {
192218
}
193219
}
194220

195-
public async checkConnection() {
221+
public async checkConnection(force: boolean = false) {
222+
if(this.connectionOK) {
223+
if(!force) {
224+
return;
225+
}
226+
this.logger.debug('Connection OK but step was forced')
227+
}
196228
try {
197229
const res = await this.doCheckConnection();
198230
if (res === undefined) {
@@ -207,7 +239,7 @@ export default abstract class AbstractComponent {
207239
this.connectionOK = true;
208240
} catch (e) {
209241
this.connectionOK = false;
210-
throw new Error('Communicating with upstream service failed', {cause: e});
242+
throw new ConnectionCheckError('Communicating with upstream service failed', {cause: e});
211243
}
212244
}
213245

@@ -227,15 +259,30 @@ export default abstract class AbstractComponent {
227259

228260
canTryAuth = () => this.isUsable() && this.authGated() && this.authFailure !== true
229261

262+
canAuthUnattended = () => !this.authGated || !this.requiresAuthInteraction || (this.requiresAuthInteraction && !this.authFailure);
263+
230264
protected doAuthentication = async (): Promise<boolean> => this.authed
231265

232266
// default init function, should be overridden if auth stage is required
233267
testAuth = async (force: boolean = false) => {
234268
if(!this.requiresAuth) {
235269
return;
236270
}
237-
if(this.authed && !force) {
238-
return;
271+
if(this.authed) {
272+
if(!force) {
273+
return;
274+
}
275+
this.logger.debug('Auth OK but step was forced');
276+
}
277+
278+
if(this.authFailure) {
279+
if(!force) {
280+
if(this.requiresAuthInteraction) {
281+
throw new AuthCheckError('Authentication failure: Will not retry auth because user interaction is required for authentication');
282+
}
283+
throw new AuthCheckError('Authentication failure: Will not retry auth because authentication previously failed and must be reauthenticated');
284+
}
285+
this.logger.debug('Auth previously failed for non upstream/network reasons but retry is being forced');
239286
}
240287

241288
try {
@@ -245,7 +292,7 @@ export default abstract class AbstractComponent {
245292
// only signal as auth failure if error was NOT either a node network error or a non-showstopping upstream error
246293
this.authFailure = !(hasNodeNetworkException(e) || hasUpstreamError(e, false));
247294
this.authed = false;
248-
this.logger.error(new Error(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e}));
295+
throw new AuthCheckError(`Authentication test failed!${this.authFailure === false ? ' Due to a network issue. Will retry authentication on next heartbeat.' : ''}`, {cause: e});
249296
}
250297
}
251298

‎src/backend/common/errors/MSErrors.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
export abstract class NamedError extends Error {
2+
public abstract name: string;
3+
}
4+
5+
export abstract class StageError extends NamedError {}
6+
7+
export class BuildDataError extends StageError {
8+
name = 'Init Build Data';
9+
}
10+
11+
export class TransformRulesError extends StageError {
12+
name = 'Transform Rules';
13+
}
14+
15+
export class ConnectionCheckError extends StageError {
16+
name = 'Conenction Check';
17+
}
18+
19+
export class AuthCheckError extends StageError {
20+
name = 'Authentication Check';
21+
}
22+
23+
export class PostInitError extends StageError {
24+
name = 'Post Initialization';
25+
}

‎src/backend/common/vendor/JRiverApiClient.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,7 @@ export class JRiverApiClient extends AbstractApiClient {
151151
if(this.config.username === undefined || this.config.password === undefined) {
152152
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
153153
}
154-
this.logger.error(new Error(msg, {cause: e}));
155-
return false;
154+
throw new Error(msg, {cause: e});
156155
}
157156
}
158157

‎src/backend/common/vendor/KodiApiClient.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,7 @@ export class KodiApiClient extends AbstractApiClient {
142142
if(this.config.username === undefined || this.config.password === undefined) {
143143
msg = 'Authentication failed. No username/password was provided in config! Did you mean to do this?';
144144
}
145-
this.logger.error(new Error(msg, {cause: e}));
146-
return false;
145+
throw new Error(msg, {cause: e});
147146
}
148147
}
149148

‎src/backend/index.ts

+25-33
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ import { getRoot, parseVersion } from "./ioc.js";
1515
import { initServer } from "./server/index.js";
1616
import { createHeartbeatClientsTask } from "./tasks/heartbeatClients.js";
1717
import { createHeartbeatSourcesTask } from "./tasks/heartbeatSources.js";
18-
import { parseBool, readJson, sleep } from "./utils.js";
18+
import { parseBool, readJson, retry, sleep } from "./utils.js";
1919
import { createVegaGenerator } from './utils/SchemaUtils.js';
20+
import ScrobbleClients from './scrobblers/ScrobbleClients.js';
21+
import ScrobbleSources from './sources/ScrobbleSources.js';
2022

2123
dayjs.extend(utc)
2224
dayjs.extend(isBetween);
@@ -106,25 +108,13 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
106108
/*
107109
* setup clients
108110
* */
109-
const scrobbleClients = root.get('clients');
111+
const scrobbleClients = root.get('clients') as ScrobbleClients;
110112
await scrobbleClients.buildClientsFromConfig(notifiers);
111-
if (scrobbleClients.clients.length === 0) {
112-
logger.warn('No scrobble clients were configured!')
113-
} else {
114-
logger.info('Starting scrobble clients...');
115-
}
116-
for(const client of scrobbleClients.clients) {
117-
await client.initScrobbleMonitoring();
118-
}
119-
120-
const scrobbleSources = root.get('sources');
113+
/*
114+
* setup sources
115+
* */
116+
const scrobbleSources = root.get('sources') as ScrobbleSources;
121117
await scrobbleSources.buildSourcesFromConfig([]);
122-
for(const source of scrobbleSources.sources) {
123-
if(!source.isReady()) {
124-
await source.initialize();
125-
}
126-
}
127-
scrobbleSources.logger.info('Finished initializing sources');
128118

129119
// check ambiguous client/source types like this for now
130120
const lastfmSources = scrobbleSources.getByType('lastfm');
@@ -136,27 +126,29 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`)
136126
logger.warn(`Last.FM source and clients have same names [${nameColl.map(x => x.name).join(',')}] -- this may cause issues`);
137127
}
138128

139-
let anyNotReady = false;
140-
for (const source of scrobbleSources.sources.filter(x => x.canPoll === true)) {
141-
await sleep(1500); // stagger polling by 1.5 seconds so that log messages for each source don't get mixed up
142-
if(source.isReady()) {
143-
source.poll();
144-
} else {
145-
anyNotReady = true;
146-
}
147-
}
148-
if (anyNotReady) {
149-
logger.info(`Some sources are not ready, open the dashboard to continue`);
129+
const clientTask = createHeartbeatClientsTask(scrobbleClients, logger);
130+
clientTask.execute();
131+
try {
132+
await retry(() => {
133+
if(clientTask.isExecuting) {
134+
throw new Error('Waiting')
135+
}
136+
return true;
137+
},{retries: scrobbleClients.clients.length + 1, retryIntervalMs: 2000});
138+
} catch (e) {
139+
logger.warn('Waited too long for clients to start! Moving ahead with sources init...');
150140
}
151-
152141
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
153142
minutes: 20,
154143
runImmediately: false
155-
}, createHeartbeatSourcesTask(scrobbleSources, logger)));
144+
}, clientTask, {id: 'clients_heart'}));
145+
146+
const sourceTask = createHeartbeatSourcesTask(scrobbleSources, logger);
156147
scheduler.addSimpleIntervalJob(new SimpleIntervalJob({
157148
minutes: 20,
158-
runImmediately: false
159-
}, createHeartbeatClientsTask(scrobbleClients, logger)));
149+
runImmediately: true
150+
}, sourceTask, {id: 'sources_heart'}));
151+
160152
logger.info('Scheduler started.');
161153

162154
} catch (e) {

0 commit comments

Comments
 (0)
Please sign in to comment.