diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index f69b5231..f810a95a 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -20,6 +20,7 @@ import { builtin } from 'typeson-registry'; import { loggerNoop } from './MaybeLogger.js'; import { ListenProgressPositional, ListenProgressTS } from '../sources/PlayerState/ListenProgress.js'; const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`); +import prom, { Gauge } from 'prom-client'; dayjs.extend(utc) dayjs.extend(isBetween); @@ -54,6 +55,12 @@ export class MSCache { logger: Logger; + cacheHits: Gauge; + cacheMisses: Gauge; + cacheSets: Gauge; + cacheCount: Gauge; + cacheVSize: Gauge; + constructor(logger: Logger, config: CacheConfigOptions = {}) { this.logger = childLogger(logger, 'Cache'); @@ -97,16 +104,100 @@ export class MSCache { this.regexCache = cacheFunctions(this.config.regex); this.cacheTransform = new Cacheable({primary: initMemoryCache({lruSize: 500})}); + this.cacheTransform.stats.enabled = true; this.cacheClientScrobbles = new Cacheable({primary: initMemoryCache({lruSize: 100, ttl: '5m'})}); + this.cacheClientScrobbles.stats.enabled = true; } init = async () => { await this.initMetadataCache(); await this.initScrobbleCache(); await this.initAuthCache(); + this.enableCollectors(); //this.cacheTransform = await this.initCacheable({provider: false, memory: {lruSize: 500}}, 'transform'); } + protected enableCollectors = () => { + + const collectors: {cache: Cacheable, name: string}[] = [ + { cache: this.cacheMetadata, name: 'metadata' }, + { cache: this.cacheScrobble, name: 'queued_scrobbles' }, + { cache: this.cacheTransform, name: 'transformer' }, + { cache: this.cacheClientScrobbles, name: 'historical_scrobbles' } + ]; + + this.cacheHits = new prom.Gauge({ + name: 'multiscrobbler_cache_hits', + help: 'cache hits', + labelNames: ['cacheType', 'tier'], + collect() { + for(const set of collectors) { + const [primary, secondary] = getStat(set.cache, 'hits'); + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); + if(secondary !== undefined) { + this.labels({cacheType: set.name, tier: 'secondary'}).set(secondary); + } + } + + } + }); + this.cacheMisses = new prom.Gauge({ + name: 'multiscrobbler_cache_misses', + help: 'cache misses', + labelNames: ['cacheType', 'tier'], + collect() { + for(const set of collectors) { + const [primary, secondary] = getStat(set.cache, 'misses'); + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); + if(secondary !== undefined) { + this.labels({cacheType: set.name, tier: 'secondary'}).set(secondary); + } + } + + } + }); + + this.cacheMisses = new prom.Gauge({ + name: 'multiscrobbler_cache_sets', + help: 'cache sets', + labelNames: ['cacheType', 'tier'], + collect() { + for(const set of collectors) { + const [primary, secondary] = getStat(set.cache, 'sets'); + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); + if(secondary !== undefined) { + this.labels({cacheType: set.name, tier: 'secondary'}).set(secondary); + } + } + + } + }); + + this.cacheCount = new prom.Gauge({ + name: 'multiscrobbler_cache_count', + help: 'number of keys in cache', + labelNames: ['cacheType', 'tier'], + collect() { + for(const set of collectors) { + const [primary] = getStat(set.cache, 'count', false); + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); + } + } + }); + + this.cacheVSize = new prom.Gauge({ + name: 'multiscrobbler_cache_vsize', + help: 'estimated byte size of values in cache', + labelNames: ['cacheType', 'tier'], + collect() { + for(const set of collectors) { + const [primary] = getStat(set.cache, 'vsize', false); + this.labels({cacheType: set.name, tier: 'primary'}).set(primary); + } + } + }); + } + protected initCacheable = async (config: CacheConfig, cacheFor: string) => { let logger = childLogger(this.logger, cacheFor); @@ -146,7 +237,9 @@ export class MSCache { if(secondaryCache !== undefined) { cacheOpts.secondary = secondaryCache; } - return new Cacheable(cacheOpts); + const cache = new Cacheable(cacheOpts); + cache.stats.enabled = true; + return cache; } @@ -200,6 +293,7 @@ export const initMemoryCache = (opts: Parameters[0] memory.serialize = (data) => { return clone(data) as string; } + memory.stats.enabled = true; return memory; } @@ -278,6 +372,7 @@ export const initFileCache = async (opts: FlatCacheOptions = {}, logger: Logger throwOnErrors: true, ...typesonMarshalling }); + cache.stats.enabled = true; return [cache, flatCache]; } catch (e) { throw e; @@ -293,6 +388,7 @@ export const valkeyCacheCreate = (ns: string, ...args: ConstructorParameters = { const data = typeson.parseSync(str); return data; } +} + +const getStat = (cache: Cacheable, statName: string, getSecondary: boolean = true): [number, number?] => { + const primary = cache.stats[statName]; + let secondary: number; + if(getSecondary && cache.secondary !== undefined) { + secondary = cache.secondary.stats[statName]; + } + return [primary, secondary]; } \ No newline at end of file -- 2.51.2 From 306c4c858e57fe01f639d4762bb11035aca49aa2 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 27 Mar 2026 18:51:00 +0000 Subject: [PATCH 2/9] fix: Only enable metrics collection for real runs --- src/backend/common/Cache.ts | 7 ++++--- src/backend/index.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index f810a95a..3576cc1d 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -109,12 +109,13 @@ export class MSCache { this.cacheClientScrobbles.stats.enabled = true; } - init = async () => { + init = async (enableCollectors: boolean = false) => { await this.initMetadataCache(); await this.initScrobbleCache(); await this.initAuthCache(); - this.enableCollectors(); - //this.cacheTransform = await this.initCacheable({provider: false, memory: {lruSize: 500}}, 'transform'); + if(enableCollectors) { + this.enableCollectors(); + } } protected enableCollectors = () => { diff --git a/src/backend/index.ts b/src/backend/index.ts index f3759c31..34036bb3 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -109,7 +109,7 @@ const configDir = process.env.CONFIG_DIR || path.resolve(projectDir, `./config`) const scrobbleClients = new ScrobbleClients(root.get('clientEmitter'), root.get('sourceEmitter'), internalConfigOptional, root.get('logger')); const scrobbleSources = new ScrobbleSources(root.get('sourceEmitter'), internalConfigOptional, root.get('logger')); - await root.items.cache().init(); + await root.items.cache().init(true); initServer(logger, appLoggerStream, output, scrobbleSources, scrobbleClients); -- 2.51.2 From 06945fda522fb5f38384756d5dc79e56743bec5c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 27 Mar 2026 20:30:32 +0000 Subject: [PATCH 3/9] feat: Improve cache stats * Remove vsize since its very inaccurate * Use memory store `size` for count, if present --- src/backend/common/Cache.ts | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index 3576cc1d..c2ce6357 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -1,4 +1,4 @@ -import { Cacheable, createKeyv, Keyv, KeyvStoreAdapter, KeyvOptions, CacheableOptions } from 'cacheable'; +import { Cacheable, createKeyv, Keyv, KeyvStoreAdapter, KeyvOptions, CacheableOptions, KeyvCacheableMemory } from 'cacheable'; import { FlatCache, FlatCacheOptions } from 'flat-cache'; import KeyvValkey from '@keyv/valkey'; import dayjs, { Dayjs } from 'dayjs'; @@ -59,7 +59,7 @@ export class MSCache { cacheMisses: Gauge; cacheSets: Gauge; cacheCount: Gauge; - cacheVSize: Gauge; + //cacheVSize: Gauge; constructor(logger: Logger, config: CacheConfigOptions = {}) { this.logger = childLogger(logger, 'Cache'); @@ -186,17 +186,17 @@ export class MSCache { } }); - this.cacheVSize = new prom.Gauge({ - name: 'multiscrobbler_cache_vsize', - help: 'estimated byte size of values in cache', - labelNames: ['cacheType', 'tier'], - collect() { - for(const set of collectors) { - const [primary] = getStat(set.cache, 'vsize', false); - this.labels({cacheType: set.name, tier: 'primary'}).set(primary); - } - } - }); + // this.cacheVSize = new prom.Gauge({ + // name: 'multiscrobbler_cache_vsize', + // help: 'estimated byte size of values in cache', + // labelNames: ['cacheType', 'tier'], + // collect() { + // for(const set of collectors) { + // const [primary] = getStat(set.cache, 'vsize', false); + // this.labels({cacheType: set.name, tier: 'primary'}).set(primary); + // } + // } + // }); } protected initCacheable = async (config: CacheConfig, cacheFor: string) => { @@ -286,6 +286,8 @@ export const initMemoryCache = (opts: Parameters[0] const memory = createKeyv({ ttl, lruSize, + // millisecond interval before checking for expired keys and deleting + checkInterval: 10000, ...restOpts, useClone: false, }); @@ -415,7 +417,10 @@ const typesonMarshalling: Pick = { } const getStat = (cache: Cacheable, statName: string, getSecondary: boolean = true): [number, number?] => { - const primary = cache.stats[statName]; + let primary = cache.stats[statName]; + if(statName === 'count' && cache.primary.store instanceof KeyvCacheableMemory) { + primary = cache.primary.store.store.size; + } let secondary: number; if(getSecondary && cache.secondary !== undefined) { secondary = cache.secondary.stats[statName]; -- 2.51.2 From a0ac0f8e46920922d69ceef8f697e1576994a3ff Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 27 Mar 2026 20:30:48 +0000 Subject: [PATCH 4/9] reduce step ttl to 2 minutes --- src/backend/common/AbstractComponent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index 8369c856..320709eb 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -312,7 +312,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { } if(cacheOk) { - await this.cache.cacheTransform.set(transformHash, steps, '10m'); + await this.cache.cacheTransform.set(transformHash, steps, '2m'); } return transformedPlay; -- 2.51.2 From 1f319cb9f60e6624a939a3ccec36effa69e62351 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Sat, 28 Mar 2026 02:01:14 +0000 Subject: [PATCH 5/9] fix: Use lifecycle invariant play for closest match --- src/backend/utils/PlayComparisonUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/utils/PlayComparisonUtils.ts b/src/backend/utils/PlayComparisonUtils.ts index e2d29b34..0a0c3f41 100644 --- a/src/backend/utils/PlayComparisonUtils.ts +++ b/src/backend/utils/PlayComparisonUtils.ts @@ -512,7 +512,7 @@ export const existingScrobble = async (playObjPre: PlayObject, existingScrobbles if (result.score <= score && score > 0) { result.reason = confidence; - result.closestMatchedPlay = x; + result.closestMatchedPlay = lifecyclelessInvariantTransform(x); result.match = score >= DUP_SCORE_THRESHOLD; result.breakdowns = scoreBreakdowns; result.score = score; -- 2.51.2 From fe03bcd65135bc0cf5ea69688f536fccbf82825c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Sat, 28 Mar 2026 21:36:18 +0000 Subject: [PATCH 6/9] refactor: Refactor cache building to be able to specify both primary and secondary --- src/backend/common/Cache.ts | 144 ++++++++++++------ src/backend/common/MaybeLogger.ts | 2 +- src/backend/common/infrastructure/Atomic.ts | 19 ++- .../tests/scrobbler/scrobblers.test.ts | 8 +- src/backend/tests/utils/CacheTestUtils.ts | 2 +- 5 files changed, 124 insertions(+), 51 deletions(-) diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index c2ce6357..81b4dd2d 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -1,6 +1,6 @@ import { Cacheable, createKeyv, Keyv, KeyvStoreAdapter, KeyvOptions, CacheableOptions, KeyvCacheableMemory } from 'cacheable'; import { FlatCache, FlatCacheOptions } from 'flat-cache'; -import KeyvValkey from '@keyv/valkey'; +import KeyvValkey, { KeyvValkeyOptions } from '@keyv/valkey'; import dayjs, { Dayjs } from 'dayjs'; import duration from 'dayjs/plugin/duration.js'; import isBetween from 'dayjs/plugin/isBetween.js'; @@ -52,6 +52,8 @@ export class MSCache { regexCache: ReturnType; cacheTransform: Cacheable; cacheClientScrobbles: Cacheable; + cacheApi: Cacheable; + hasInit: boolean = false; logger: Logger; @@ -66,7 +68,7 @@ export class MSCache { const { metadata: { - provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadataProvider | undefined) ?? 'memory'), + provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadataProvider | undefined) ?? false), connection: mConn = process.env.CACHE_METADATA_CONN, ...restMetadata } = {}, @@ -103,16 +105,29 @@ export class MSCache { }; this.regexCache = cacheFunctions(this.config.regex); - this.cacheTransform = new Cacheable({primary: initMemoryCache({lruSize: 500})}); - this.cacheTransform.stats.enabled = true; - this.cacheClientScrobbles = new Cacheable({primary: initMemoryCache({lruSize: 100, ttl: '5m'})}); - this.cacheClientScrobbles.stats.enabled = true; + + // for testing we default to in memory + const inMemory = new Cacheable({primary: initMemoryCache({lruSize: 500, ttl: '1m'})}); + this.cacheTransform = inMemory; + this.cacheClientScrobbles = inMemory; + this.cacheMetadata = inMemory; + this.cacheAuth = inMemory; + this.cacheScrobble = inMemory; + this.cacheApi = inMemory; } init = async (enableCollectors: boolean = false) => { await this.initMetadataCache(); await this.initScrobbleCache(); await this.initAuthCache(); + + this.cacheClientScrobbles = await this.initCacheable('Historical Scrobbles', {provider: 'memory', ttl: '2m', lruSize: 50}, {...this.config.metadata, ttl: '10m'}); + this.cacheClientScrobbles.stats.enabled = true; + this.cacheTransform = await this.initCacheable('Historical Scrobbles', {provider: 'memory', ttl: '2m', lruSize: 100}, {...this.config.metadata, ttl: '5m'}); + this.cacheTransform.stats.enabled = true; + this.cacheApi = await this.initCacheable('External API Responses', {provider: 'memory', ttl: '30s', lruSize: 100}, {...this.config.metadata, ttl: '5m'}); + this.cacheApi.stats.enabled = true; + if(enableCollectors) { this.enableCollectors(); } @@ -124,7 +139,8 @@ export class MSCache { { cache: this.cacheMetadata, name: 'metadata' }, { cache: this.cacheScrobble, name: 'queued_scrobbles' }, { cache: this.cacheTransform, name: 'transformer' }, - { cache: this.cacheClientScrobbles, name: 'historical_scrobbles' } + { cache: this.cacheClientScrobbles, name: 'historical_scrobbles' }, + { cache: this.cacheApi, name: 'external_apis' } ]; this.cacheHits = new prom.Gauge({ @@ -199,78 +215,107 @@ export class MSCache { // }); } - protected initCacheable = async (config: CacheConfig, cacheFor: string) => { + protected initCacheable = async (cacheFor: string, primaryConfig: CacheConfig, secondaryConfig?: CacheConfig) => { let logger = childLogger(this.logger, cacheFor); - const providerHints = ['In-Memory (Primary)']; - if(config.provider !== false) { - providerHints.push(`${config.provider} (Secondary)`) + const providerHints = []; + if(primaryConfig.provider === false) { + const cache = new Cacheable({primary: noopKeyv}); + cache.stats.enabled = true; + logger.verbose(`Cache Providers: Disabled`); + return cache; } - logger.verbose(`Cache Providers: ${providerHints.join(' | ')}`) + + providerHints.push(`${primaryConfig.provider} (Primary)`) + + if(secondaryConfig === undefined || secondaryConfig.provider !== false) { + providerHints.push(`Disabled (Secondary)`) + } else { + providerHints.push(`${secondaryConfig.provider} (Secondary)`); + } + logger.verbose(`Cache Providers: ${providerHints.join(' | ')}`); const ns = `ms-${cacheFor.toLocaleLowerCase()}`; const cacheOpts: CacheableOptions = { - primary: initMemoryCache({ namespace: ns, lruSize: config.memory?.lruSize, ttl: config.memory?.ttl }) + } - let secondaryCache: Keyv | KeyvStoreAdapter | undefined; + try { + cacheOpts.primary = await this.initCachableType(ns, primaryConfig, logger); + } catch (e) { + throw new Error('Could not init primary cache', {cause: e}); + } - if (config.provider === 'valkey') { - logger.debug(`Building valkey cache from ${config.connection}`); + if(secondaryConfig !== undefined && secondaryConfig.provider !== false) { try { - secondaryCache = await initValkeyCache(ns, config.connection); - logger.debug('valkey cache connected'); + cacheOpts.secondary = await this.initCachableType(ns, secondaryConfig, logger); } catch (e) { this.logger.warn(e); } - } else if (config.provider === 'file') { - logger.debug(`Building file cache from ${path.join(config.connection, `${ns}.cache`)}`); + } + + const cache = new Cacheable(cacheOpts); + cache.stats.enabled = true; + return cache; + + } + + protected initCachableType = async (namespace: string, config: CacheConfig, logger: Logger): Promise | KeyvStoreAdapter> => { + if (config.provider === 'memory') { + return initMemoryCache({ namespace, lruSize: config.lruSize, ttl: config.ttl }); + } + + if (config.provider === 'valkey') { + logger.debug(`Building valkey cache from ${config.connection}`); try { - const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection, cacheId: `${ns}.cache` }, logger); - secondaryCache = keyvFile; + const cache = await initValkeyCache(namespace, config.connection, undefined, {ttl: config.ttl}); + logger.debug('valkey cache connected'); + return cache; } catch (e) { - logger.warn(e); + throw e; } } + if (config.provider === 'file') { + logger.debug(`Building file cache from ${path.join(config.connection, `${namespace}.cache`)}`); - if(secondaryCache !== undefined) { - cacheOpts.secondary = secondaryCache; + try { + const [keyvFile] = await initFileCache({ ...config, cacheDir: config.connection, cacheId: `${namespace}.cache` }, {ttl: config.ttl}, logger); + return keyvFile; + } catch (e) { + throw e; + } } - const cache = new Cacheable(cacheOpts); - cache.stats.enabled = true; - return cache; - } initScrobbleCache = async () => { - if (this.cacheScrobble === undefined) { + if (!this.hasInit) { if (!asCacheScrobbleProvider(this.config.scrobble.provider)) { throw new Error(`Cache Scrobble provider '${this.config.scrobble.provider}' must be one of: memory, valkey, file`); } - this.cacheScrobble = await this.initCacheable(this.config.scrobble, 'Scrobble'); + this.cacheScrobble = await this.initCacheable('Scrobble', this.config.scrobble); } } initMetadataCache = async () => { - if (this.cacheMetadata === undefined) { + if (!this.hasInit) { if (!asCacheMetadataProvider(this.config.metadata.provider)) { throw new Error(`Cache Metadata provider '${this.config.metadata.provider}' must be one of: memory, valkey`); } - this.cacheMetadata = await this.initCacheable(this.config.metadata, 'Metadata'); + this.cacheMetadata = await this.initCacheable('Metadata', {provider: 'memory', lruSize: 100, ttl: '3m'}, {...this.config.metadata, ttl: '15m'}); } } initAuthCache = async () => { - if (this.cacheAuth === undefined) { + if (!this.hasInit) { if (!asCacheAuthProvider(this.config.auth.provider)) { throw new Error(`Cache Auth provider '${this.config.auth.provider}' must be one of: memory, valkey, file`); } - this.cacheAuth = await this.initCacheable(this.config.auth, 'Auth'); + this.cacheAuth = await this.initCacheable('Auth', {provider: 'memory', ttl: '3m'}, this.config.auth); } } @@ -279,7 +324,7 @@ export class MSCache { export const initMemoryCache = (opts: Parameters[0] = {}): Keyv | KeyvStoreAdapter => { const { - ttl = '1h', + ttl = '60s', lruSize = 200, ...restOpts } = opts; @@ -359,7 +404,7 @@ export const flatCacheLoad = async (flatCache: FlatCache, logger: Logger = logge } } -export const initFileCache = async (opts: FlatCacheOptions = {}, logger: Logger = loggerNoop): Promise<[Keyv | KeyvStoreAdapter | undefined, FlatCache | undefined]> => { +export const initFileCache = async (opts: FlatCacheOptions = {}, keyvOpts: KeyvOptions = {}, logger: Logger = loggerNoop): Promise<[Keyv | KeyvStoreAdapter | undefined, FlatCache | undefined]> => { const flatCache = flatCacheCreate(opts); try { await flatCacheLoad(flatCache, logger); @@ -373,7 +418,8 @@ export const initFileCache = async (opts: FlatCacheOptions = {}, logger: Logger const cache = new Keyv({ store: flatCache, throwOnErrors: true, - ...typesonMarshalling + ...typesonMarshalling, + ...keyvOpts }); cache.stats.enabled = true; return [cache, flatCache]; @@ -382,26 +428,26 @@ export const initFileCache = async (opts: FlatCacheOptions = {}, logger: Logger } } -export const valkeyCacheCreate = (ns: string, ...args: ConstructorParameters): Keyv => { - const [connection, valkeyOpts = {}] = args; +export const valkeyCacheCreate = (ns: string, connection: string, valkeyOpts: KeyvValkeyOptions = {}, keyvOpts: KeyvOptions = {}): Keyv => { const valkey = new KeyvValkey(connection, { maxRetriesPerRequest: 5, connectTimeout: 1100, ...valkeyOpts }); const kv = new Keyv({ store: valkey, throwOnErrors: true, namespace: ns, - ...typesonMarshalling + ...typesonMarshalling, + ...keyvOpts }); kv.stats.enabled = true; return kv; } -export const initValkeyCache = async (ns: string, ...args: ConstructorParameters): Promise => { - const kv = valkeyCacheCreate(ns, ...args); +export const initValkeyCache = async (ns: string, connection: string, valkeyOpts: KeyvValkeyOptions = {}, keyvOpts: KeyvOptions = {}): Promise => { + const kv = valkeyCacheCreate(ns, connection, valkeyOpts, keyvOpts); try { await kv.get('test'); return kv; } catch (e) { - throw new Error(`Unable to connect to cache ${args[0]}`, { cause: e }) + throw new Error(`Unable to connect to cache ${connection}`, { cause: e }) } } @@ -426,4 +472,14 @@ const getStat = (cache: Cacheable, statName: string, getSecondary: boolean = tru secondary = cache.secondary.stats[statName]; } return [primary, secondary]; +} + +const noopKeyv: KeyvStoreAdapter = { + opts: {}, + namespace: 'noop', + get: (_) => undefined, + set: (_, __, ___) => undefined, + delete: (_) => undefined, + clear: () => Promise.resolve(), + on: (_, __) => undefined } \ No newline at end of file diff --git a/src/backend/common/MaybeLogger.ts b/src/backend/common/MaybeLogger.ts index 8d8443f3..ca681a8a 100644 --- a/src/backend/common/MaybeLogger.ts +++ b/src/backend/common/MaybeLogger.ts @@ -48,7 +48,7 @@ export class MaybeLogger { } } } -export const noopLog = (_: any, ...rest: any) => undefined; +const noopLog = (_: any, ...rest: any) => undefined; export const loggerNoop: Logger = { trace: noopLog, diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index e4c4f13f..7ce7607b 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -230,8 +230,21 @@ export interface CacheConfig { connection?: string; [key: string]: any } +export interface CacheMemoryConfig extends CacheConfig<'memory'> { + lruSize?: number + ttl?: number +} +export interface CacheValkeyConfig extends CacheConfig<'valkey'> { +} +export interface CacheFileConfig extends CacheConfig<'file'> { +} +export interface CacheNoopConfig extends CacheConfig { +} + +export type CacheConfigType = CacheMemoryConfig | CacheValkeyConfig | CacheFileConfig | CacheNoopConfig; + export type CacheMetadataProvider = CacheProvider;//Exclude; -export type CacheMetadataConfig = CacheConfig; +export type CacheMetadataConfig = CacheConfigType; export const asCacheProvider = (val: boolean | string): val is CacheProvider => { if(typeof val === 'string') { return ['memory', 'valkey', 'file'].includes(val); @@ -240,11 +253,11 @@ export const asCacheProvider = (val: boolean | string): val is CacheProvider => } export const asCacheMetadataProvider = (val: any): val is CacheScrobbleProvider => asCacheProvider(val); export type CacheScrobbleProvider = CacheProvider; -export type CacheScrobbleConfig = CacheConfig; +export type CacheScrobbleConfig = CacheConfigType; export const asCacheScrobbleProvider = (val: any): val is CacheScrobbleProvider => asCacheProvider(val); export type CacheAuthProvider = CacheProvider; -export type CacheAuthConfig = CacheConfig; +export type CacheAuthConfig = CacheConfigType; export const asCacheAuthProvider = (val: any): val is CacheAuthProvider => asCacheProvider(val); export interface CacheConfigOptions { metadata?: CacheMetadataConfig; diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index 90fdf60f..3420259a 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -22,6 +22,8 @@ import { shuffleArray } from '../../utils/DataUtils.js'; import { DEFAULT_CONSOLIDATE_DURATION, DEFAULT_GROUP_DURATION, groupPlaysToTimeRanges } from '../../utils/ListenFetchUtils.js'; import { asPlay } from '../../../core/tests/utils/fixtures.js'; import { nanoid } from 'nanoid'; +import { getRoot } from '../../ioc.js'; +import { transientCache } from '../utils/CacheTestUtils.js'; chai.use(asPromised); @@ -511,6 +513,8 @@ describe('Upstream Scrobbles', function() { afterEach(function () { MockDate.reset(); + const root = getRoot(); + root.upsert({ cache: () => transientCache }); }); it('Calls timerange func to get SOT scrobbles when none exists', async function() { @@ -547,7 +551,7 @@ describe('Upstream Scrobbles', function() { scrobbler.startScrobbling().then(() => null); await emptied; scrobbler.tryStopScrobbling().then(() => null); - expect(sp.calledOnce).is.true; + expect(sp.callCount).to.eq(1); }); it('Uses separate timerange calls when scrobbles are not closely grouped', async function() { @@ -567,7 +571,7 @@ describe('Upstream Scrobbles', function() { scrobbler.startScrobbling().then(() => null); await emptied; scrobbler.tryStopScrobbling().then(() => null); - expect(sp.calledTwice).is.true; + expect(sp.callCount).to.eq(2); }); it('Gets fresh timerange if TTL of staleAfter has passed', async function() { diff --git a/src/backend/tests/utils/CacheTestUtils.ts b/src/backend/tests/utils/CacheTestUtils.ts index a8784271..3b537f41 100644 --- a/src/backend/tests/utils/CacheTestUtils.ts +++ b/src/backend/tests/utils/CacheTestUtils.ts @@ -1,4 +1,4 @@ import { loggerTest } from "@foxxmd/logging"; import { MSCache } from "../../common/Cache.js"; -export const transientCache = () => new MSCache(loggerTest, {scrobble: {provider: 'memory'}, auth: {provider: 'memory'}}); \ No newline at end of file +export const transientCache = () => new MSCache(loggerTest, {scrobble: {provider: 'memory'}, auth: {provider: 'memory'}, metadata: {provider: 'memory'}}); \ No newline at end of file -- 2.51.2 From 972eccd0bf1a6774257681f18494d5e344d7f54e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 30 Mar 2026 18:50:54 +0000 Subject: [PATCH 7/9] cache init cleanup --- src/backend/common/Cache.ts | 53 +++++++++++++-------- src/backend/common/infrastructure/Atomic.ts | 20 ++++++++ 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index 81b4dd2d..8483489c 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -14,7 +14,7 @@ import { projectDir } from './index.js'; import path from 'path'; import { cacheFunctions } from "@foxxmd/regex-buddy-core"; import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.js'; -import { asCacheAuthProvider, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js'; +import { asCacheAuthProvider, asCacheConfig, asCacheMetadataProvider, asCacheScrobbleProvider, CacheAuthProvider, CacheConfig, CacheConfigOptions, CacheMetadataProvider, CacheProvider, CacheScrobbleProvider } from './infrastructure/Atomic.js'; import { Typeson } from 'typeson'; import { builtin } from 'typeson-registry'; import { loggerNoop } from './MaybeLogger.js'; @@ -121,13 +121,6 @@ export class MSCache { await this.initScrobbleCache(); await this.initAuthCache(); - this.cacheClientScrobbles = await this.initCacheable('Historical Scrobbles', {provider: 'memory', ttl: '2m', lruSize: 50}, {...this.config.metadata, ttl: '10m'}); - this.cacheClientScrobbles.stats.enabled = true; - this.cacheTransform = await this.initCacheable('Historical Scrobbles', {provider: 'memory', ttl: '2m', lruSize: 100}, {...this.config.metadata, ttl: '5m'}); - this.cacheTransform.stats.enabled = true; - this.cacheApi = await this.initCacheable('External API Responses', {provider: 'memory', ttl: '30s', lruSize: 100}, {...this.config.metadata, ttl: '5m'}); - this.cacheApi.stats.enabled = true; - if(enableCollectors) { this.enableCollectors(); } @@ -291,31 +284,53 @@ export class MSCache { initScrobbleCache = async () => { if (!this.hasInit) { - if (!asCacheScrobbleProvider(this.config.scrobble.provider)) { - throw new Error(`Cache Scrobble provider '${this.config.scrobble.provider}' must be one of: memory, valkey, file`); + let scrobbleConfig: CacheConfig | undefined; + try { + if(asCacheConfig(this.config.scrobble)) { + scrobbleConfig = this.config.scrobble; + this.cacheScrobble = await this.initCacheable('Scrobble', this.config.scrobble); + } + } catch (e) { + this.logger.warn(new Error('Could not validate scrobble config! No fallback is possible', {cause: e})); + this.cacheScrobble = await this.initCacheable('Scrobble', {provider: false}); } - - this.cacheScrobble = await this.initCacheable('Scrobble', this.config.scrobble); } } initMetadataCache = async () => { if (!this.hasInit) { - if (!asCacheMetadataProvider(this.config.metadata.provider)) { - throw new Error(`Cache Metadata provider '${this.config.metadata.provider}' must be one of: memory, valkey`); + let metadataConfig: CacheConfig | undefined; + try { + if(asCacheConfig(this.config.metadata)) { + metadataConfig = this.config.metadata; + } + } catch (e) { + this.logger.warn(new Error('Could not validate metadata config, will fallback to memory cache only', {cause: e})); } - - this.cacheMetadata = await this.initCacheable('Metadata', {provider: 'memory', lruSize: 100, ttl: '3m'}, {...this.config.metadata, ttl: '15m'}); + this.cacheMetadata = await this.initCacheable('Metadata', {provider: 'memory', ttl: '3m', lruSize: 100}, metadataConfig === undefined ? undefined : {...this.config.metadata, ttl: '15m'}); + this.cacheMetadata.stats.enabled = true; + this.cacheClientScrobbles = await this.initCacheable('Historical Scrobbles', {provider: 'memory', ttl: '2m', lruSize: 50}, metadataConfig === undefined ? undefined : {...this.config.metadata, ttl: '10m'}); + this.cacheClientScrobbles.stats.enabled = true; + this.cacheTransform = await this.initCacheable('Transform Data', {provider: 'memory', ttl: '2m', lruSize: 100}, metadataConfig === undefined ? undefined : {...this.config.metadata, ttl: '5m'}); + this.cacheTransform.stats.enabled = true; + this.cacheApi = await this.initCacheable('External API Responses', {provider: 'memory', ttl: '30s', lruSize: 100}, metadataConfig === undefined ? undefined : {...this.config.metadata, ttl: '20m'}); + this.cacheApi.stats.enabled = true; } } initAuthCache = async () => { if (!this.hasInit) { - if (!asCacheAuthProvider(this.config.auth.provider)) { - throw new Error(`Cache Auth provider '${this.config.auth.provider}' must be one of: memory, valkey, file`); + let authConfig: CacheConfig | undefined; + try { + if(asCacheConfig(this.config.scrobble)) { + authConfig = this.config.auth; + } + } catch (e) { + this.logger.warn(new Error('Could not validate auth config! will fallback to memory cache only', {cause: e})); + this.cacheScrobble = await this.initCacheable('Scrobble', {provider: false}); } - this.cacheAuth = await this.initCacheable('Auth', {provider: 'memory', ttl: '3m'}, this.config.auth); + this.cacheAuth = await this.initCacheable('Auth', {provider: 'memory', ttl: '3m'}, authConfig); } } diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 7ce7607b..8e6afb4b 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -8,6 +8,8 @@ import TupleMap from "../TupleMap.js"; import { MusicBrainzApi } from 'musicbrainz-api'; import { SourceType } from './config/source/sources.js'; import { ClientType, clientTypes } from './config/client/clients.js'; +import assert, { AssertionError } from 'assert'; +import { SimpleError } from '../errors/MSErrors.js'; export const lowGranularitySources: SourceType[] = ['subsonic', 'ytmusic']; @@ -251,6 +253,24 @@ export const asCacheProvider = (val: boolean | string): val is CacheProvider => } return val === false; } +export const asCacheEphemeralConfig = (val: Record): val is (CacheMemoryConfig | CacheNoopConfig) => { + return val.provider === false || val.provider === 'memory'; +} +export const asCacheConnectableConfig = (val: Record): val is (CacheValkeyConfig | CacheFileConfig) => { + if(!['valkey', 'file'].includes(val.provider)) { + return false; + } + return typeof val.connection === 'string'; +} +export const asCacheConfig = (val: Record): val is CacheConfigType => { + if(!asCacheProvider(val.provider)) { + assert(asCacheProvider(val.provider), `Cache provider must be one of: 'memory', 'valkey', 'file', or false`); + } + if(asCacheConnectableConfig(val) || asCacheEphemeralConfig(val)) { + return true; + } + throw new SimpleError(`${val.provider} must have a connection property that is a string`); +} export const asCacheMetadataProvider = (val: any): val is CacheScrobbleProvider => asCacheProvider(val); export type CacheScrobbleProvider = CacheProvider; export type CacheScrobbleConfig = CacheConfigType; -- 2.51.2 From 7c8c185c4cc70b4cffdeebe8a3782be0cd71372a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 30 Mar 2026 18:59:37 +0000 Subject: [PATCH 8/9] refactor: Remove ttl from mb api to ensure primary/secondary ttl is used correctly --- src/backend/common/infrastructure/Atomic.ts | 1 - .../vendor/musicbrainz/MusicbrainzApiClient.ts | 14 +++++--------- src/backend/ioc.ts | 4 ++-- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 8e6afb4b..b3fcba02 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -294,7 +294,6 @@ export interface MusicbrainzApiConfigData { url?: string rateLimit?: [number, number] contact: string, - ttl?: string apiKey?: string requestTimeout?: number } diff --git a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts index e7bbde71..a51e091f 100644 --- a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts +++ b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts @@ -45,7 +45,6 @@ export interface SearchOptions { escapeCharacters?: boolean removeCharacters?: boolean, using?: UsingTypes[] - ttl?: string, freetext?: boolean } @@ -61,7 +60,7 @@ export class MusicbrainzApiClient extends AbstractApiClient { super('Musicbrainz', name, config, options); this.asyncStore = new AsyncLocalStorage(); - this.cache = options.cache ?? getRoot().items.cache().cacheMetadata; + this.cache = options.cache ?? getRoot().items.cache().cacheApi; const mbMap = getRoot().items.mbMap(); const mbApis: Record = {}; for(const mbConfig of this.config.apis) { @@ -76,7 +75,7 @@ export class MusicbrainzApiClient extends AbstractApiClient { rateLimit: mbConfig.rateLimit ?? [1,1], preRequest: options.logUrl === true || isDebugMode() ? (method, url, headers) => { const cacheKey = this.asyncStore.getStore() ?? nanoid(); - this.cache.set(`${cacheKey}-url`, `${method} - ${url}`, mbConfig.ttl ?? '1hr'); + this.cache.set(`${cacheKey}-url`, `${method} - ${url}`); if(mbConfig.apiKey !== undefined) { headers.set('X-Api_key', mbConfig.apiKey); } @@ -101,13 +100,12 @@ export class MusicbrainzApiClient extends AbstractApiClient { return 'API'; } - callApi = async (func: (mb: MusicBrainzApi) => Promise, options?: { timeout?: number, ttl?: string, cacheKey?: string }): Promise => { + callApi = async (func: (mb: MusicBrainzApi) => Promise, options?: { timeout?: number, cacheKey?: string }): Promise => { let apiConfig = this.rrApis.next().value; const { timeout = 30000, - ttl = apiConfig.ttl ?? '1hr', cacheKey } = options || {}; @@ -132,7 +130,7 @@ export class MusicbrainzApiClient extends AbstractApiClient { try { const res = await this.callApiEndpoint(apiConfig.api, func, options); if(cacheKey !== undefined) { - await this.cache.set(cacheKey, res, ttl); + await this.cache.set(cacheKey, res); } return res as T; } catch (e) { @@ -202,7 +200,6 @@ export class MusicbrainzApiClient extends AbstractApiClient { escapeCharacters = true, removeCharacters = false, using = ['album','artist','title'], - ttl, freetext } = options || {}; @@ -311,13 +308,12 @@ export class MusicbrainzApiClient extends AbstractApiClient { this.logger.debug(`Search Query => ${q}`); - this.cache.set(`${cacheKey}-qs`, q, '1hr'); + this.cache.set(`${cacheKey}-qs`, q); return mb.search('recording', { query: q }); }, { - ttl, cacheKey }); diff --git a/src/backend/ioc.ts b/src/backend/ioc.ts index 9bd2da09..7d299c4a 100644 --- a/src/backend/ioc.ts +++ b/src/backend/ioc.ts @@ -116,8 +116,8 @@ const createRoot = (options: RootOptions = {logger: loggerDebug}) => { transformerManager.register({type: 'native', name: 'MSDefault'}); } - const metadataCache = (maybeSingletonCache !== undefined ? maybeSingletonCache : cacheFunc()).cacheMetadata; - const coverArtApi = new CoverArtApiClient('', {}, {logger, cache: metadataCache}); + const cacheApi = (maybeSingletonCache !== undefined ? maybeSingletonCache : cacheFunc()).cacheApi; + const coverArtApi = new CoverArtApiClient('', {}, {logger, cache: cacheApi}); const portVal: number | string = process.env.PORT ?? port; -- 2.51.2 From 007d553d59f806341d26eea633f4354a9ea38119 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 30 Mar 2026 19:24:57 +0000 Subject: [PATCH 9/9] fix missed ttl for mb tests --- src/backend/tests/musicbrainz/musicbrainz.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/backend/tests/musicbrainz/musicbrainz.test.ts b/src/backend/tests/musicbrainz/musicbrainz.test.ts index a9c396ec..018a72aa 100644 --- a/src/backend/tests/musicbrainz/musicbrainz.test.ts +++ b/src/backend/tests/musicbrainz/musicbrainz.test.ts @@ -24,7 +24,6 @@ const memorycache = () => new Cacheable({ primary: initMemoryCache({ ttl: '1ms' const defaultApiConfig: MusicbrainzApiConfigData = { contact: 'contact@foxxmd.dev', - ttl: '1ms' }; const createMbTransformer = (apis: MusicbrainzApiConfigData[] = [defaultApiConfig]) => { @@ -33,9 +32,6 @@ const createMbTransformer = (apis: MusicbrainzApiConfigData[] = [defaultApiConfi type: 'musicbrainz', data: { apis - }, - options: { - ttl: '1ms' } }, { logger: loggerTest,