Let the server apply saved settings, and reload only for older servers #16

Merged
jcoffey-dev merged 1 commits from feature/server-settings-reload into main 2026-09-24 20:13:16 +00:00
6 changed files with 437 additions and 41 deletions
+1
View File
@@ -353,6 +353,7 @@
"failedObject": "The problem is in {{object}}.",
"noAnswer": "The server did not answer.",
"notConfirmed": "The server did not confirm the reload.",
"notReloaded": "The server did not reload its settings.",
"openObject": "Open it",
"stillRunning": "The server keeps running on the settings it had. Your changes are saved and apply once this is fixed."
},
+78 -3
View File
@@ -8,10 +8,12 @@ import { describe, it, expect } from 'vitest';
import {
describeApplyFailure,
describeRequestFailure,
describeServerReload,
registryWrites,
reloadActionFor,
reloadActionsFor,
serverAppliesWrite,
writesRegistry,
writtenRegistryTypes,
} from './settingsApply';
import type { JmapMethodResponse } from '@/types/jmap';
@@ -38,8 +40,26 @@ describe('reloadActionFor', () => {
expect(reloadActionFor('x:Authentication')).toBeNull();
});
it('reloads allowed IPs in full: they are part of the settings, not the blocked list', () => {
expect(reloadActionFor('x:AllowedIp')).toBe('ReloadSettings');
});
it('skips data read live, operations and stores', () => {
for (const type of ['Account', 'Domain', 'DkimSignature', 'Tenant', 'Action', 'QueuedMessage', 'DataStore']) {
for (const type of [
'Account',
'Alert',
'DnsServer',
'Domain',
'DkimSignature',
'Enterprise',
'SpamLlm',
'Tenant',
'Action',
'QueuedMessage',
'DataStore',
'MetricsStore',
'TracingStore',
]) {
expect(reloadActionFor(`x:${type}`)).toBeNull();
}
});
@@ -78,7 +98,62 @@ describe('registry writes', () => {
['x:Certificate/set', { destroyed: ['c1'] }, '2'],
['error', { type: 'serverFail' }, '3'],
];
expect(writtenRegistryTypes(responses)).toEqual(['x:MtaRoute', 'x:Certificate']);
expect(registryWrites(responses)).toEqual([{ objectName: 'x:MtaRoute' }, { objectName: 'x:Certificate' }]);
});
it("reads the server's own reload report where there is one", () => {
const responses: JmapMethodResponse[] = [
['x:MtaRoute/set', { updated: { a: null }, 'x:settingsReload': { applied: true } }, '0'],
[
'x:Tracer/set',
{
created: { t: { id: 't1' } },
'x:settingsReload': { applied: false, description: 'Saved, but the running settings were not reloaded. x' },
},
'1',
],
['x:Domain/set', { created: { d: { id: 'd1' } } }, '2'],
['x:MtaHook/set', { updated: { h: null }, 'x:settingsReload': 'yes' }, '3'],
];
expect(registryWrites(responses)).toEqual([
{ objectName: 'x:MtaRoute', serverReload: { applied: true } },
{
objectName: 'x:Tracer',
serverReload: { applied: false, description: 'Saved, but the running settings were not reloaded. x' },
},
{ objectName: 'x:Domain' },
// Not the shape a server sends: treated as absent.
{ objectName: 'x:MtaHook' },
]);
});
it('leaves applying to the server when it reported, except for allowed IPs', () => {
expect(serverAppliesWrite({ objectName: 'x:MtaRoute', serverReload: { applied: true } })).toBe(true);
expect(serverAppliesWrite({ objectName: 'x:MtaRoute', serverReload: { applied: false } })).toBe(true);
expect(serverAppliesWrite({ objectName: 'x:MtaRoute' })).toBe(false);
expect(serverAppliesWrite({ objectName: 'x:AllowedIp', serverReload: { applied: true } })).toBe(false);
});
});
describe('describeServerReload', () => {
it('drops the lead-in the banner already says and names the object', () => {
expect(
describeServerReload({
applied: false,
description:
'Saved, but the running settings were not reloaded. Tracer with id b: Only one console tracer is allowed',
}),
).toEqual({
message: 'Tracer with id b: Only one console tracer is allowed',
object: { object: 'Tracer', id: 'b' },
});
});
it('keeps a description it does not recognize, and copes with none', () => {
expect(describeServerReload({ applied: false, description: 'Store unavailable' })).toEqual({
message: 'Store unavailable',
});
expect(describeServerReload({ applied: false })).toEqual({ message: 'The server did not reload its settings.' });
});
});
+73 -8
View File
@@ -11,9 +11,15 @@
* A write to the registry is stored at once, but most settings only take
* effect when the server rebuilds its configuration from the registry: the
* x:Action ReloadSettings action, which also carries the change to every
* node of a cluster. The server does that by itself on a write for a few
* types only (directories and the default authentication). Everything else
* waited for someone to open Management > Actions and reload by hand.
* node of a cluster. Older servers do that by themselves on a write for a few
* types only (directories and the default authentication); everything else
* waits for a reload, which the admin sends (settingsApplyStore).
*
* Newer servers reload after every write that needs it and say how it went
* in the set response's `x:settingsReload` ({applied, description}), absent
* when the write needed no reload. A write that carries it needs nothing from
* the admin; the table below is what an older server, which never sends it,
* still needs, and it follows the server's own list of what each type needs.
*
* A reload is all or nothing: the new configuration replaces the running one
* only when every settings object builds, so applying straight after a save
@@ -43,6 +49,8 @@ const OWN_ACTION: Record<string, ReloadAction> = {
MemoryLookupKey: 'ReloadLookupStores',
MemoryLookupKeyValue: 'ReloadLookupStores',
BlockedIp: 'ReloadBlockedIps',
// Not AllowedIp: allowed addresses are part of the full settings, and only
// ReloadSettings rebuilds them.
};
/** Types that need no reload after a write. Anything not listed here is reloaded. */
@@ -54,15 +62,19 @@ const NOTHING_TO_APPLY = new Set<string>([
'Account',
'AccountPassword',
'AccountSettings',
'Alert',
'ApiKey',
'AppPassword',
'DnsServer',
'Domain',
'DkimSignature',
'Enterprise',
'MailingList',
'MaskedEmail',
'OAuthClient',
'PublicKey',
'Role',
'SpamLlm',
'Tenant',
// Operations, records and telemetry rather than settings.
'Action',
@@ -86,7 +98,9 @@ const NOTHING_TO_APPLY = new Set<string>([
'Coordinator',
'DataStore',
'InMemoryStore',
'MetricsStore',
'SearchStore',
'TracingStore',
// Applications are unpacked by their own manager, which no reload reaches.
'Application',
]);
@@ -110,9 +124,43 @@ export function writesRegistry(methodCalls: JmapMethodCall[]): boolean {
return methodCalls.some(([name]) => REGISTRY_SET.test(name));
}
/** The registry types a response says were created, changed or destroyed. */
export function writtenRegistryTypes(methodResponses: JmapMethodResponse[]): string[] {
const types = new Set<string>();
/** What a newer server says about applying a registry write (`x:settingsReload`). */
export interface ServerReload {
/** The running settings, on every node, include the write. */
applied: boolean;
/** Why they don't, when they don't. */
description?: string;
}
/** A registry type a request created, changed or destroyed, and what the server said about applying it. */
export interface RegistryWrite {
objectName: string;
/** Absent from older servers, and from newer ones when the write needed no reload. */
serverReload?: ServerReload;
}
/**
* Types whose `x:settingsReload` doesn't tell the whole story. The server
* answers an AllowedIp write with the blocked-IP reload, but allowed
* addresses are only rebuilt by a full reload, so the admin still sends one.
*/
const SERVER_RELOAD_INCOMPLETE = new Set<string>(['x:AllowedIp']);
/** Whether a write's `x:settingsReload` means the admin has nothing to send for it. */
export function serverAppliesWrite(write: RegistryWrite): boolean {
return write.serverReload !== undefined && !SERVER_RELOAD_INCOMPLETE.has(write.objectName);
}
function readServerReload(value: unknown): ServerReload | undefined {
if (!value || typeof value !== 'object') return undefined;
const { applied, description } = value as { applied?: unknown; description?: unknown };
if (typeof applied !== 'boolean') return undefined;
return typeof description === 'string' && description ? { applied, description } : { applied };
}
/** The registry writes in a response: one per `x:<Type>/set` that created, changed or destroyed something. */
export function registryWrites(methodResponses: JmapMethodResponse[]): RegistryWrite[] {
const writes: RegistryWrite[] = [];
for (const [name, result] of methodResponses) {
const match = REGISTRY_SET.exec(name);
if (!match || !result) continue;
@@ -124,10 +172,11 @@ export function writtenRegistryTypes(methodResponses: JmapMethodResponse[]): str
(updated && Object.keys(updated).length > 0) ||
(destroyed && destroyed.length > 0)
) {
types.add(match[1]);
const serverReload = readServerReload(result['x:settingsReload']);
writes.push(serverReload ? { objectName: match[1], serverReload } : { objectName: match[1] });
}
}
return [...types];
return writes;
}
/** The actions due for a set of written types, in the order they run. */
@@ -172,3 +221,19 @@ export function describeRequestFailure(err: unknown): ApplyFailure {
}
return { message: i18n.t('settingsApply.noAnswer', 'The server did not answer.') };
}
/** How the server starts a refused reload's description; the banner says the same in its own words. */
const SERVER_RELOAD_PREFIX = 'Saved, but the running settings were not reloaded. ';
/** How the server names the object that didn't build: "<Type> with id <id>: <error>". */
const SERVER_RELOAD_OBJECT = /^([A-Z][A-Za-z0-9]*) with id ([^\s:]+): /;
/** Reads a server's refused reload (`x:settingsReload` with applied: false) into something to show. */
export function describeServerReload(reload: ServerReload): ApplyFailure {
let message = reload.description?.trim() ?? '';
if (message.startsWith(SERVER_RELOAD_PREFIX)) message = message.slice(SERVER_RELOAD_PREFIX.length).trim();
if (!message) {
return { message: i18n.t('settingsApply.notReloaded', 'The server did not reload its settings.') };
}
const match = SERVER_RELOAD_OBJECT.exec(message);
return match ? { message, object: { object: match[1], id: match[2] } } : { message };
}
+6 -5
View File
@@ -12,7 +12,7 @@ import { apiFetch } from '@/services/api';
import { logJmapExchange } from '@/lib/debug';
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
import type { Schema } from '@/types/schema';
import { writesRegistry, writtenRegistryTypes } from '@/lib/settingsApply';
import { registryWrites, writesRegistry, type RegistryWrite } from '@/lib/settingsApply';
const JMAP_USING = [
'urn:ietf:params:jmap:core',
@@ -39,11 +39,12 @@ export function getAccountId(objectType: string): string {
/**
* inbuxa: told about every registry write, so that saved settings can be
* applied on the server (settingsApplyStore). `started` comes before the
* request goes out and `finished` after it settles, with the types it changed.
* request goes out and `finished` after it settles, with the types it changed
* and what the server said about applying them.
*/
export interface RegistryWriteListener {
started(): void;
finished(objectNames: string[]): void;
finished(writes: RegistryWrite[]): void;
}
let registryWriteListener: RegistryWriteListener | null = null;
@@ -60,10 +61,10 @@ export async function jmapRequest(
const listener = writesRegistry(methodCalls) ? registryWriteListener : null;
if (!listener) return sendJmapRequest(methodCalls, signal, extraUsing);
listener.started();
let written: string[] = [];
let written: RegistryWrite[] = [];
try {
const responses = await sendJmapRequest(methodCalls, signal, extraUsing);
written = writtenRegistryTypes(responses);
written = registryWrites(responses);
return responses;
} finally {
listener.finished(written);
+184 -2
View File
@@ -6,6 +6,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { RegistryWriteListener } from '@/services/jmap/client';
import type { RegistryWrite } from '@/lib/settingsApply';
import type { JmapMethodCall, JmapMethodResponse } from '@/types/jmap';
const mocks = vi.hoisted(() => ({
@@ -26,11 +27,22 @@ vi.mock('@/hooks/use-toast', () => ({ toast: mocks.toast }));
import { APPLY_DELAY_MS, resetSettingsApplyForTests, useSettingsApplyStore } from './settingsApplyStore';
// A save on an older server: no x:settingsReload in the response.
function save(...types: string[]) {
mocks.listener!.started();
mocks.listener!.finished(types);
mocks.listener!.finished(types.map((objectName) => ({ objectName })));
}
// A save on a newer server, which applied it or said why not.
function serverSave(objectName: string, applied: boolean, description?: string) {
const write: RegistryWrite = { objectName, serverReload: description ? { applied, description } : { applied } };
mocks.listener!.started();
mocks.listener!.finished([write]);
}
const REFUSED =
'Saved, but the running settings were not reloaded. Tracer with id b: Only one console tracer is allowed';
function reloadCreates(call: JmapMethodCall): unknown[] {
expect(call[0]).toBe('x:Action/set');
return Object.values((call[1] as { create: Record<string, unknown> }).create);
@@ -80,7 +92,7 @@ describe('settingsApplyStore', () => {
mocks.listener!.started();
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 5);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
mocks.listener!.finished(['x:Certificate']);
mocks.listener!.finished([{ objectName: 'x:Certificate' }]);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
@@ -164,4 +176,174 @@ describe('settingsApplyStore', () => {
useSettingsApplyStore.getState().dismiss();
expect(useSettingsApplyStore.getState()).toMatchObject({ failure: null, pending: ['ReloadSettings'] });
});
describe('a server that applies writes itself', () => {
it('sends nothing and says "Saved and applied" once per burst', async () => {
serverSave('x:MtaDeliverySchedule', true);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS / 2);
serverSave('x:MtaRoute', true);
serverSave('x:Certificate', true);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledTimes(1);
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Saved and applied', variant: 'success' }),
);
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
});
it("leaves the toast to the form for types it reloads that the admin doesn't", async () => {
serverSave('x:Directory', true);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
expect(mocks.toast).not.toHaveBeenCalled();
});
it("shows the server's reason, and Apply now sends ReloadSettings", async () => {
serverSave('x:Tracer', false, REFUSED);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
expect(mocks.toast).not.toHaveBeenCalled();
expect(useSettingsApplyStore.getState()).toMatchObject({
pending: ['ReloadSettings'],
failure: {
message: 'Tracer with id b: Only one console tracer is allowed',
object: { object: 'Tracer', id: 'b' },
},
});
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
await useSettingsApplyStore.getState().applyNow();
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Saved and applied' }));
});
it('lets a later write in the burst that applied have the last word', async () => {
serverSave('x:Tracer', false, REFUSED);
serverSave('x:Tracer', true);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
expect(mocks.toast).toHaveBeenCalledTimes(1);
});
it('clears an earlier failure once a later write applies', async () => {
serverSave('x:Tracer', false, REFUSED);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(useSettingsApplyStore.getState().failure).not.toBeNull();
serverSave('x:Tracer', true);
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledTimes(1);
});
it('does not retry a refused reload on an unrelated save', async () => {
serverSave('x:Tracer', false, REFUSED);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
save('x:Account');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
expect(useSettingsApplyStore.getState().failure).not.toBeNull();
});
it('still reloads allowed IPs in full: the report only covers the blocked list', async () => {
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
serverSave('x:AllowedIp', true);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
expect(mocks.toast).toHaveBeenCalledTimes(1);
});
});
describe('a burst mixing both kinds of answer', () => {
it('sends only what the server left to the admin, and says so once', async () => {
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
serverSave('x:MtaRoute', true);
serverSave('x:Certificate', true);
// A type the server doesn't answer for, as an older node would.
save('x:SomethingNew');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
expect(reloadCreates(mocks.jmapRequest.mock.calls[0][0][0])).toEqual([{ '@type': 'ReloadSettings' }]);
expect(mocks.toast).toHaveBeenCalledTimes(1);
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
});
it("doesn't take back a reload queued after the server applied one", async () => {
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
save('x:MtaRoute');
serverSave('x:MtaHook', true);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
// The server's reload came after the first write and covers it.
expect(mocks.jmapRequest).not.toHaveBeenCalled();
expect(mocks.toast).toHaveBeenCalledTimes(1);
// Nothing is left over to send with a later refusal either.
serverSave('x:Tracer', false, REFUSED);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).not.toHaveBeenCalled();
serverSave('x:Tracer', true);
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.toast).toHaveBeenCalledTimes(2);
serverSave('x:MtaHook', true);
save('x:MtaRoute');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
expect(mocks.toast).toHaveBeenCalledTimes(3);
});
it("lets the admin's reload settle a server refusal in the same burst", async () => {
mocks.jmapRequest.mockResolvedValueOnce(answer(['reload-0']));
serverSave('x:Tracer', false, REFUSED);
save('x:SomethingNew');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
expect(useSettingsApplyStore.getState()).toMatchObject({ pending: [], failure: null });
expect(mocks.toast).toHaveBeenCalledTimes(1);
});
it('shows a refusal once when the admin reload fails too', async () => {
mocks.jmapRequest.mockResolvedValueOnce(
answer([], { 'reload-0': { type: 'validationFailed', description: 'Only one console tracer is allowed' } }),
);
serverSave('x:Tracer', false, REFUSED);
save('x:SomethingNew');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS * 2);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
expect(mocks.toast).not.toHaveBeenCalled();
expect(useSettingsApplyStore.getState()).toMatchObject({
pending: ['ReloadSettings'],
failure: { message: 'Only one console tracer is allowed' },
});
});
it('reports a server answer that lands while a reload is out once it is back', async () => {
let answerReload: (r: JmapMethodResponse[]) => void = () => {};
mocks.jmapRequest.mockReturnValueOnce(
new Promise((resolve) => {
answerReload = resolve;
}),
);
save('x:MtaRoute');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(useSettingsApplyStore.getState().applying).toBe(true);
serverSave('x:Certificate', false, 'Saved, but the running settings were not reloaded. Bad key');
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
answerReload(answer(['reload-0']));
await vi.advanceTimersByTimeAsync(APPLY_DELAY_MS);
expect(mocks.jmapRequest).toHaveBeenCalledTimes(1);
expect(useSettingsApplyStore.getState()).toMatchObject({
pending: ['ReloadTlsCertificates'],
failure: { message: 'Bad key' },
});
});
});
});
+95 -23
View File
@@ -7,15 +7,18 @@
/**
* inbuxa: applies saved settings on the running server.
*
* Every registry write that needs it queues a reload action (see
* lib/settingsApply). Once no write has been in flight for APPLY_DELAY_MS the
* queued actions go out together in one x:Action/set, so a bulk edit, or a
* page that saves several objects in a row, costs one reload rather than one
* per object. A reload that fails stays queued and shows in
* SettingsApplyBanner until it is applied, by "Apply now" or by the next save.
* A newer server applies a registry write itself and says how it went in the
* set response (`x:settingsReload`); the admin only reports that. An older
* server doesn't, so every write there that needs it queues a reload action
* (see lib/settingsApply). Once no write has been in flight for APPLY_DELAY_MS
* the queued actions go out together in one x:Action/set, so a bulk edit, or
* a page that saves several objects in a row, costs one reload rather than
* one per object.
*
* If the server one day applies these writes by itself, the reload sent here
* is a second, harmless one: one per burst of saves, never one per object.
* Either way a burst of saves ends in one result: a "Saved and applied" toast,
* or SettingsApplyBanner with why the settings weren't applied. What wasn't
* applied stays queued until it is, by "Apply now", by the next save that
* needs the same reload, or by the server applying a later write of that kind.
*/
import { create } from 'zustand';
@@ -26,8 +29,11 @@ import {
RELOAD_ORDER,
describeApplyFailure,
describeRequestFailure,
reloadActionsFor,
describeServerReload,
reloadActionFor,
serverAppliesWrite,
type ApplyFailure,
type RegistryWrite,
type ReloadAction,
} from '@/lib/settingsApply';
import type { JmapSetError } from '@/types/jmap';
@@ -35,13 +41,13 @@ import type { JmapSetError } from '@/types/jmap';
export const APPLY_DELAY_MS = 600;
interface SettingsApplyState {
/** Actions waiting to be sent: queued by saves, or left over from a failed attempt. */
/** Actions not known to be applied: queued by saves, or left over from a failed attempt. */
pending: ReloadAction[];
applying: boolean;
/** Why the last attempt didn't apply, until one does. */
failure: ApplyFailure | null;
/** Queues the actions the written types need, and applies them once writes settle. */
noteWrites: (objectNames: string[]) => void;
/** Takes note of registry writes: what the server applied, and what the admin has to. */
noteWrites: (writes: RegistryWrite[]) => void;
/** Sends whatever is queued now, without waiting. */
applyNow: () => Promise<void>;
/** Hides the failure. What failed stays queued for the next save. */
@@ -55,6 +61,12 @@ let applyAgain = false;
// reload, only a save that needs one tries again by itself; editing an
// account, say, doesn't rerun a reload that is known to fail.
let freshlyQueued = false;
// What the server reported for the writes since the burst last settled: that
// it applied a settings object, and the reloads it couldn't apply (the last
// word per action).
let serverReported = false;
let serverApplied = false;
const serverFailures = new Map<ReloadAction, ApplyFailure>();
function merge(a: ReloadAction[], b: ReloadAction[]): ReloadAction[] {
const all = new Set([...a, ...b]);
@@ -64,25 +76,78 @@ function merge(a: ReloadAction[], b: ReloadAction[]): ReloadAction[] {
function schedule() {
if (timer) clearTimeout(timer);
timer = null;
if (writesInFlight > 0) return;
const { pending, failure } = useSettingsApplyStore.getState();
if (pending.length === 0 || writesInFlight > 0) return;
if (failure && !freshlyQueued) return;
const send = pending.length > 0 && (freshlyQueued || (!failure && serverFailures.size === 0));
if (!send && !serverReported) return;
timer = setTimeout(() => {
timer = null;
void useSettingsApplyStore.getState().applyNow();
settle();
}, APPLY_DELAY_MS);
}
/** Ends a burst of saves: sends what the admin has to, or reports what the server did. */
function settle() {
const store = useSettingsApplyStore.getState();
if (store.applying) {
// Report once the reload that is out comes back.
applyAgain = true;
return;
}
const serverFailure = [...serverFailures.values()].pop() ?? null;
const announce = serverApplied;
serverReported = false;
serverApplied = false;
serverFailures.clear();
if (serverFailure) useSettingsApplyStore.setState({ failure: serverFailure });
const { pending, failure } = useSettingsApplyStore.getState();
if (pending.length > 0 && (freshlyQueued || !failure)) {
// An older server's writes, or a reload a dismissed failure left queued:
// the admin's reload speaks for the whole burst.
void store.applyNow();
return;
}
if (announce && !failure) {
toast({ title: i18n.t('settingsApply.applied', 'Saved and applied'), variant: 'success' });
}
}
export const useSettingsApplyStore = create<SettingsApplyState>()((set, get) => ({
pending: [],
applying: false,
failure: null,
noteWrites: (objectNames) => {
const due = reloadActionsFor(objectNames);
if (due.length > 0) {
freshlyQueued = true;
set({ pending: merge(get().pending, due) });
noteWrites: (writes) => {
let pending = get().pending;
for (const write of writes) {
const own = reloadActionFor(write.objectName);
if (write.serverReload && serverAppliesWrite(write)) {
// The server has applied this write, or tried to: nothing to send.
// A type the admin sends nothing for, a directory say, was a full reload.
const action = own ?? 'ReloadSettings';
serverReported = true;
if (write.serverReload.applied) {
serverFailures.delete(action);
pending = pending.filter((a) => a !== action);
// Only settings objects say "Saved and applied"; the rest have their form's toast.
if (own) serverApplied = true;
} else {
serverFailures.set(action, describeServerReload(write.serverReload));
pending = merge(pending, [action]);
}
} else if (own) {
// An older server: the admin applies it.
freshlyQueued = true;
pending = merge(pending, [own]);
}
}
if (pending.length === 0) {
// The server has applied everything queued, and whatever failed before.
freshlyQueued = false;
set({ pending, failure: null });
} else {
set({ pending });
}
schedule();
},
@@ -99,6 +164,9 @@ export const useSettingsApplyStore = create<SettingsApplyState>()((set, get) =>
if (actions.length === 0) return;
freshlyQueued = false;
// This reload comes after every write so far, so it has the last word on
// what the server said about them.
for (const action of actions) serverFailures.delete(action);
set({ applying: true, pending: [] });
let notApplied: ReloadAction[] = [];
let failure: ApplyFailure | null = null;
@@ -133,10 +201,11 @@ export const useSettingsApplyStore = create<SettingsApplyState>()((set, get) =>
set({ applying: false, pending: merge(get().pending, notApplied), failure });
if (!failure) {
serverApplied = false;
toast({ title: i18n.t('settingsApply.applied', 'Saved and applied'), variant: 'success' });
}
if (applyAgain) {
if (applyAgain || serverReported) {
applyAgain = false;
schedule();
}
@@ -151,9 +220,9 @@ setRegistryWriteListener({
if (timer) clearTimeout(timer);
timer = null;
},
finished(objectNames) {
finished(writes) {
writesInFlight = Math.max(0, writesInFlight - 1);
useSettingsApplyStore.getState().noteWrites(objectNames);
useSettingsApplyStore.getState().noteWrites(writes);
},
});
@@ -164,5 +233,8 @@ export function resetSettingsApplyForTests() {
writesInFlight = 0;
applyAgain = false;
freshlyQueued = false;
serverReported = false;
serverApplied = false;
serverFailures.clear();
useSettingsApplyStore.setState({ pending: [], applying: false, failure: null });
}