Skip to content

Commit 34075e4

Browse files
fix: flaky watch on boot drive's dynamix config (#1753)
On FAT32, `fs.stat()` updates accesstime, which means file reads are also writes, which means we can't use `usePoll` without degrading users' flash drives. To keep file reads lazy without a larger refactor, I override `getters.dynamix()` as the entrypoint to re-read the boot drive's dynamix config. Consecutive calls to `getters.dynamix()` are a common access pattern, which means we have to memoize to avoid many redundant file reads, so I used a TTL cache with a 250ms lifetime, hoping to scope config files to each request. `getters.dynamix()` is also used synchonously, so bit the bullet and switched away from async reads for simplicity, considering that most reads will be occurring from memory, even during cache misses. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a TTL memoized loader utility with exported types. * Added a public function to load Dynamix configuration at startup. * **Refactor** * Startup now uses the deterministic, cached config loader; runtime file-watch for Dynamix config removed. * Simplified config state handling and load-status reporting for more predictable startup behavior. * **Tests** * Added tests for TTL caching, eviction, keying, and conditional caching. * **Chores** * Bumped package versions and updated changelog. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent ff2906e commit 34075e4

File tree

21 files changed

+256
-92
lines changed

21 files changed

+256
-92
lines changed

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{".":"4.25.2"}
1+
{".":"4.25.3"}

api/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## [4.25.3](https://github.com/unraid/unraid-api/compare/v4.25.2...v4.25.3) (2025-10-22)
4+
5+
6+
### Bug Fixes
7+
8+
* flaky watch on boot drive's dynamix config ([ec7aa06](https://github.com/unraid/unraid-api/commit/ec7aa06d4a5fb1f0e84420266b0b0d7ee09a3663))
9+
310
## [4.25.2](https://github.com/unraid/api/compare/v4.25.1...v4.25.2) (2025-09-30)
411

512

api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@unraid/api",
3-
"version": "4.25.2",
3+
"version": "4.25.3",
44
"main": "src/cli/index.ts",
55
"type": "module",
66
"corepack": {

api/src/__test__/core/utils/images/image-file-helpers.test.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,18 @@ import {
44
getBannerPathIfPresent,
55
getCasePathIfPresent,
66
} from '@app/core/utils/images/image-file-helpers.js';
7-
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
8-
import { store } from '@app/store/index.js';
7+
import { loadDynamixConfig } from '@app/store/index.js';
98

109
test('get case path returns expected result', async () => {
1110
await expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png');
1211
});
1312

14-
test('get banner path returns null (state unloaded)', async () => {
15-
await expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null');
16-
});
17-
1813
test('get banner path returns the banner (state loaded)', async () => {
19-
await store.dispatch(loadDynamixConfigFile()).unwrap();
14+
loadDynamixConfig();
2015
await expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png');
2116
});
2217

2318
test('get banner path returns null when no banner (state loaded)', async () => {
24-
await store.dispatch(loadDynamixConfigFile()).unwrap();
19+
loadDynamixConfig();
2520
await expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null');
2621
});

api/src/index.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,11 @@ import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
1818
import { getServerIdentifier } from '@app/core/utils/server-identifier.js';
1919
import { environment, PATHS_CONFIG_MODULES, PORT } from '@app/environment.js';
2020
import * as envVars from '@app/environment.js';
21-
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
2221
import { shutdownApiEvent } from '@app/store/actions/shutdown-api-event.js';
23-
import { store } from '@app/store/index.js';
22+
import { loadDynamixConfig, store } from '@app/store/index.js';
2423
import { startMiddlewareListeners } from '@app/store/listeners/listener-middleware.js';
2524
import { loadStateFiles } from '@app/store/modules/emhttp.js';
2625
import { loadRegistrationKey } from '@app/store/modules/registration.js';
27-
import { setupDynamixConfigWatch } from '@app/store/watch/dynamix-config-watch.js';
2826
import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch.js';
2927
import { StateManager } from '@app/store/watch/state-watch.js';
3028

@@ -76,17 +74,14 @@ export const viteNodeApp = async () => {
7674
await store.dispatch(loadRegistrationKey());
7775

7876
// Load my dynamix config file into store
79-
await store.dispatch(loadDynamixConfigFile());
77+
loadDynamixConfig();
8078

8179
// Start listening to file updates
8280
StateManager.getInstance();
8381

8482
// Start listening to key file changes
8583
setupRegistrationKeyWatch();
8684

87-
// Start listening to dynamix config file changes
88-
setupDynamixConfigWatch();
89-
9085
// If port is unix socket, delete old socket before starting http server
9186
unlinkUnixPort();
9287

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
1-
import { F_OK } from 'constants';
2-
import { access } from 'fs/promises';
3-
4-
import { createAsyncThunk } from '@reduxjs/toolkit';
1+
import { createTtlMemoizedLoader } from '@unraid/shared';
52

3+
import type { RecursivePartial } from '@app/types/index.js';
64
import { type DynamixConfig } from '@app/core/types/ini.js';
5+
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
76
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
8-
import { type RecursiveNullable, type RecursivePartial } from '@app/types/index.js';
9-
import { batchProcess } from '@app/utils.js';
107

118
/**
129
* Loads a configuration file from disk, parses it to a RecursivePartial of the provided type, and returns it.
@@ -16,33 +13,49 @@ import { batchProcess } from '@app/utils.js';
1613
* @param path The path to the configuration file on disk.
1714
* @returns A parsed RecursivePartial of the provided type.
1815
*/
19-
async function loadConfigFile<ConfigType>(path: string): Promise<RecursivePartial<ConfigType>> {
20-
const fileIsAccessible = await access(path, F_OK)
21-
.then(() => true)
22-
.catch(() => false);
23-
return fileIsAccessible
16+
function loadConfigFileSync<ConfigType>(path: string): RecursivePartial<ConfigType> {
17+
return fileExistsSync(path)
2418
? parseConfig<RecursivePartial<ConfigType>>({
2519
filePath: path,
2620
type: 'ini',
2721
})
2822
: {};
2923
}
3024

25+
type ConfigPaths = readonly (string | undefined | null)[];
26+
const CACHE_WINDOW_MS = 250;
27+
28+
const memoizedConfigLoader = createTtlMemoizedLoader<
29+
RecursivePartial<DynamixConfig>,
30+
ConfigPaths,
31+
string
32+
>({
33+
ttlMs: CACHE_WINDOW_MS,
34+
getCacheKey: (configPaths: ConfigPaths): string => JSON.stringify(configPaths),
35+
load: (configPaths: ConfigPaths) => {
36+
const validPaths = configPaths.filter((path): path is string => Boolean(path));
37+
if (validPaths.length === 0) {
38+
return {};
39+
}
40+
const configFiles = validPaths.map((path) => loadConfigFileSync<DynamixConfig>(path));
41+
return configFiles.reduce<RecursivePartial<DynamixConfig>>(
42+
(accumulator, configFile) => ({
43+
...accumulator,
44+
...configFile,
45+
}),
46+
{}
47+
);
48+
},
49+
});
50+
3151
/**
32-
* Load the dynamix.cfg into the store.
52+
* Loads dynamix config from disk with TTL caching.
3353
*
34-
* Note: If the file doesn't exist this will fallback to default values.
54+
* @param configPaths - Array of config file paths to load and merge
55+
* @returns Merged config object from all valid paths
3556
*/
36-
export const loadDynamixConfigFile = createAsyncThunk<
37-
RecursiveNullable<RecursivePartial<DynamixConfig>>,
38-
string | undefined
39-
>('config/load-dynamix-config-file', async (filePath) => {
40-
if (filePath) {
41-
return loadConfigFile<DynamixConfig>(filePath);
42-
}
43-
const store = await import('@app/store/index.js');
44-
const paths = store.getters.paths()['dynamix-config'];
45-
const { data: configs } = await batchProcess(paths, (path) => loadConfigFile<DynamixConfig>(path));
46-
const [defaultConfig = {}, customConfig = {}] = configs;
47-
return { ...defaultConfig, ...customConfig };
48-
});
57+
export const loadDynamixConfigFromDiskSync = (
58+
configPaths: readonly (string | undefined | null)[]
59+
): RecursivePartial<DynamixConfig> => {
60+
return memoizedConfigLoader.get(configPaths);
61+
};

api/src/store/index.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { configureStore } from '@reduxjs/toolkit';
22

3+
import { logger } from '@app/core/log.js';
4+
import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js';
35
import { listenerMiddleware } from '@app/store/listeners/listener-middleware.js';
6+
import { updateDynamixConfig } from '@app/store/modules/dynamix.js';
47
import { rootReducer } from '@app/store/root-reducer.js';
8+
import { FileLoadStatus } from '@app/store/types.js';
59

610
export const store = configureStore({
711
reducer: rootReducer,
@@ -15,8 +19,36 @@ export type RootState = ReturnType<typeof store.getState>;
1519
export type AppDispatch = typeof store.dispatch;
1620
export type ApiStore = typeof store;
1721

22+
// loadDynamixConfig is located here and not in the actions/load-dynamix-config-file.js file because it needs to access the store,
23+
// and injecting it seemed circular and convoluted for this use case.
24+
/**
25+
* Loads the dynamix config into the store.
26+
* Can be called multiple times - uses TTL caching internally.
27+
* @returns The loaded dynamix config.
28+
*/
29+
export const loadDynamixConfig = () => {
30+
const configPaths = store.getState().paths['dynamix-config'] ?? [];
31+
try {
32+
const config = loadDynamixConfigFromDiskSync(configPaths);
33+
store.dispatch(
34+
updateDynamixConfig({
35+
...config,
36+
status: FileLoadStatus.LOADED,
37+
})
38+
);
39+
} catch (error) {
40+
logger.error(error, 'Failed to load dynamix config from disk');
41+
store.dispatch(
42+
updateDynamixConfig({
43+
status: FileLoadStatus.FAILED_LOADING,
44+
})
45+
);
46+
}
47+
return store.getState().dynamix;
48+
};
49+
1850
export const getters = {
19-
dynamix: () => store.getState().dynamix,
51+
dynamix: () => loadDynamixConfig(),
2052
emhttp: () => store.getState().emhttp,
2153
paths: () => store.getState().paths,
2254
registration: () => store.getState().registration,

api/src/store/modules/dynamix.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { PayloadAction } from '@reduxjs/toolkit';
22
import { createSlice } from '@reduxjs/toolkit';
33

44
import { type DynamixConfig } from '@app/core/types/ini.js';
5-
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
65
import { FileLoadStatus } from '@app/store/types.js';
76
import { RecursivePartial } from '@app/types/index.js';
87

@@ -22,24 +21,6 @@ export const dynamix = createSlice({
2221
return Object.assign(state, action.payload);
2322
},
2423
},
25-
extraReducers(builder) {
26-
builder.addCase(loadDynamixConfigFile.pending, (state) => {
27-
state.status = FileLoadStatus.LOADING;
28-
});
29-
30-
builder.addCase(loadDynamixConfigFile.fulfilled, (state, action) => {
31-
return {
32-
...(action.payload as DynamixConfig),
33-
status: FileLoadStatus.LOADED,
34-
};
35-
});
36-
37-
builder.addCase(loadDynamixConfigFile.rejected, (state, action) => {
38-
Object.assign(state, action.payload, {
39-
status: FileLoadStatus.FAILED_LOADING,
40-
});
41-
});
42-
},
4324
});
4425

4526
export const { updateDynamixConfig } = dynamix.actions;

api/src/store/watch/dynamix-config-watch.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

api/src/unraid-api/app/__test__/app.module.integration.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import { AuthZGuard } from 'nest-authz';
66
import request from 'supertest';
77
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
88

9-
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
10-
import { store } from '@app/store/index.js';
9+
import { loadDynamixConfig, store } from '@app/store/index.js';
1110
import { loadStateFiles } from '@app/store/modules/emhttp.js';
1211
import { AppModule } from '@app/unraid-api/app/app.module.js';
1312
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
@@ -111,8 +110,8 @@ describe('AppModule Integration Tests', () => {
111110

112111
beforeAll(async () => {
113112
// Initialize the dynamix config and state files before creating the module
114-
await store.dispatch(loadDynamixConfigFile());
115113
await store.dispatch(loadStateFiles());
114+
loadDynamixConfig();
116115

117116
// Debug: Log the CSRF token from the store
118117
const { getters } = await import('@app/store/index.js');

0 commit comments

Comments
 (0)