blob: 9f104faeaa9f46b652eb18ed463bb561fe7ecfe1 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
interface SettingIntent<Value> {
desired: Value;
committed?: Value;
committedAtCatalogRevision?: number;
committedAtSessionRevision?: number;
inFlight: boolean;
completion: Promise<boolean>;
resolveCompletion(succeeded: boolean): void;
}
export interface SessionSettingIntentRevisionWriteResult {
readonly committed: boolean;
readonly sessionRevision: number;
}
export type SessionSettingIntentWriteResult =
| boolean
| SessionSettingIntentRevisionWriteResult;
interface SessionSettingIntentChannelBase<Value> {
isEqual?(left: Value, right: Value): boolean;
onWriteError(sessionId: string, error: unknown, attempted: Value): void;
}
export type SessionSettingIntentChannel<Value> = SessionSettingIntentChannelBase<Value> & (
| {
write(sessionId: string, value: Value): Promise<boolean>;
catalogSessionRevision?: never;
}
| {
write(
sessionId: string,
value: Value,
): Promise<SessionSettingIntentRevisionWriteResult>;
catalogSessionRevision(sessionId: string): number | undefined;
}
);
export interface SessionSettingIntentOptions<Values extends object> {
catalogRevision: number;
refreshCatalog(): Promise<unknown>;
channels: {
[Channel in keyof Values]: SessionSettingIntentChannel<Values[Channel]>;
};
}
export type SessionSettingIntentOverlays<Values extends object> = {
[Channel in keyof Values]: Readonly<Record<string, Values[Channel]>>;
};
export interface SessionSettingIntentController<Values extends object> {
overlayByChannel: SessionSettingIntentOverlays<Values>;
request<Channel extends keyof Values>(
channel: Channel,
sessionId: string,
value: Values[Channel],
): Promise<boolean>;
clear(sessionId: string): void;
}
type AnyIntent = SettingIntent<unknown>;
function createIntent<Value>(
desired: Value,
previous?: SettingIntent<Value>,
): SettingIntent<Value> {
let resolveCompletion!: (succeeded: boolean) => void;
const completion = new Promise<boolean>((resolve) => {
resolveCompletion = resolve;
});
return {
desired,
...(previous?.committed !== undefined ? { committed: previous.committed } : {}),
...(previous?.committedAtCatalogRevision !== undefined
? { committedAtCatalogRevision: previous.committedAtCatalogRevision }
: {}),
...(previous?.committedAtSessionRevision !== undefined
? { committedAtSessionRevision: previous.committedAtSessionRevision }
: {}),
inFlight: true,
completion,
resolveCompletion,
};
}
/**
* Owns optimistic setting overlays across independent channels. Each
* channel/session pair has its own latest-intent worker, so unrelated setting
* changes can converge concurrently without overwriting one another.
*/
export function useSessionSettingIntent<Values extends object>(
options: SessionSettingIntentOptions<Values>,
): SessionSettingIntentController<Values> {
const optionsRef = useRef(options);
optionsRef.current = options;
const intentsRef = useRef(new Map<keyof Values, Map<string, AnyIntent>>());
const mountedRef = useRef(false);
const [overlayByChannel, setOverlayByChannel] = useState<
SessionSettingIntentOverlays<Values>
>(() => Object.fromEntries(
Reflect.ownKeys(options.channels).map((channel) => [channel, {}]),
) as SessionSettingIntentOverlays<Values>);
const isEqual = useCallback(<Channel extends keyof Values>(
channel: Channel,
left: Values[Channel],
right: Values[Channel],
): boolean => {
const compare = optionsRef.current.channels[channel].isEqual;
return compare ? compare(left, right) : Object.is(left, right);
}, []);
const setOverlay = useCallback(<Channel extends keyof Values>(
channel: Channel,
sessionId: string,
value: Values[Channel] | undefined,
): void => {
setOverlayByChannel((current) => {
const channelOverlay = current[channel] as Readonly<Record<string, Values[Channel]>>;
if (value !== undefined) {
if (sessionId in channelOverlay && isEqual(channel, channelOverlay[sessionId]!, value)) {
return current;
}
return { ...current, [channel]: { ...channelOverlay, [sessionId]: value } };
}
if (!(sessionId in channelOverlay)) return current;
const next = { ...channelOverlay };
delete next[sessionId];
return { ...current, [channel]: next };
});
}, [isEqual]);
const reconcile = useCallback(<Channel extends keyof Values>(
channel: Channel,
sessionId: string,
): void => {
const channelIntents = intentsRef.current.get(channel);
const intent = channelIntents?.get(sessionId);
if (!intent || intent.inFlight || intent.committedAtCatalogRevision === undefined) return;
if (intent.committedAtSessionRevision !== undefined) {
const observedRevision = optionsRef.current.channels[channel].catalogSessionRevision?.(
sessionId,
);
if (
observedRevision === undefined ||
observedRevision < intent.committedAtSessionRevision
) {
return;
}
} else if (optionsRef.current.catalogRevision <= intent.committedAtCatalogRevision) {
return;
}
channelIntents?.delete(sessionId);
setOverlay(channel, sessionId, undefined);
}, [setOverlay]);
useEffect(() => {
for (const [channel, intents] of intentsRef.current) {
for (const sessionId of intents.keys()) reconcile(channel, sessionId);
}
}, [options.catalogRevision, reconcile]);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
const channels = intentsRef.current;
intentsRef.current = new Map();
for (const intents of channels.values()) {
for (const intent of intents.values()) intent.resolveCompletion(false);
}
};
}, []);
const request = useCallback(<Channel extends keyof Values>(
channel: Channel,
sessionId: string,
value: Values[Channel],
): Promise<boolean> => {
if (!mountedRef.current) return Promise.resolve(false);
let channelIntents = intentsRef.current.get(channel);
if (!channelIntents) {
channelIntents = new Map();
intentsRef.current.set(channel, channelIntents);
}
const typedIntents = channelIntents as Map<string, SettingIntent<Values[Channel]>>;
const existing = typedIntents.get(sessionId);
if (existing) {
if (!existing.inFlight && existing.committed !== undefined &&
isEqual(channel, existing.committed, value)) {
return Promise.resolve(true);
}
existing.desired = value;
setOverlay(channel, sessionId, value);
if (existing.inFlight) return existing.completion;
}
const intent = createIntent(value, existing);
typedIntents.set(sessionId, intent);
setOverlay(channel, sessionId, value);
void (async () => {
let terminalSucceeded = false;
while (mountedRef.current && typedIntents.get(sessionId) === intent) {
const attempted = intent.desired;
let committed = false;
let committedSessionRevision: number | undefined;
let writeError: unknown;
try {
const result = await optionsRef.current.channels[channel].write(sessionId, attempted);
if (typeof result === 'boolean') {
committed = result;
} else {
committed = result.committed;
committedSessionRevision = result.sessionRevision;
}
} catch (error) {
writeError = error;
}
if (!mountedRef.current || typedIntents.get(sessionId) !== intent) return;
if (committed) {
intent.committed = attempted;
intent.committedAtCatalogRevision = optionsRef.current.catalogRevision;
intent.committedAtSessionRevision = committedSessionRevision;
if (isEqual(channel, intent.desired, attempted)) {
setOverlay(channel, sessionId, attempted);
}
try {
void optionsRef.current.refreshCatalog().catch(() => {});
} catch {}
if (isEqual(channel, intent.desired, attempted)) {
terminalSucceeded = true;
break;
}
continue;
}
if (isEqual(channel, intent.desired, attempted)) {
try {
optionsRef.current.channels[channel].onWriteError(
sessionId,
writeError,
intent.desired,
);
} catch {}
if (!mountedRef.current || typedIntents.get(sessionId) !== intent) return;
if (!isEqual(channel, intent.desired, attempted)) continue;
setOverlay(channel, sessionId, intent.committed);
break;
}
}
if (mountedRef.current && typedIntents.get(sessionId) === intent) {
intent.inFlight = false;
if (intent.committedAtCatalogRevision === undefined) {
typedIntents.delete(sessionId);
setOverlay(channel, sessionId, undefined);
} else {
reconcile(channel, sessionId);
}
intent.resolveCompletion(terminalSucceeded);
}
})();
return intent.completion;
}, [isEqual, reconcile, setOverlay]);
const clear = useCallback((sessionId: string): void => {
for (const [channel, intents] of intentsRef.current) {
const intent = intents.get(sessionId);
intents.delete(sessionId);
intent?.resolveCompletion(false);
setOverlay(channel, sessionId, undefined);
}
}, [setOverlay]);
return { overlayByChannel, request, clear };
}