This commit is contained in:
seb
2026-10-13 17:19:33 +02:00
parent cd530ed448
commit 345d6f5752
11 changed files with 4482 additions and 4 deletions

View File

@@ -77,6 +77,8 @@ export {
isOrderDelivered,
getNegativeSeekCutoff,
formatNegativeSeek,
getChunkStats,
subscribeChunkStats,
} from './orderAutomat';
export { default as entityAutomat } from './entityAutomat';
export { default as customergroupAutomat } from './customergroupAutomat';

View File

@@ -74,13 +74,91 @@ let pendingChunkReject = null;
const loadedChunks = new Set();
const inflightChunks = new Map();
// Dev / Observability: Chunk event history and subscriber registry
const MAX_CHUNK_EVENTS = 120;
let nextEventId = 0;
const chunkEvents = [];
const chunkStatsListeners = new Set();
function recordChunkEvent(entry) {
const event = {
id: ++nextEventId,
time: Date.now(),
epoch: queryEpoch,
...entry,
};
chunkEvents.push(event);
if (chunkEvents.length > MAX_CHUNK_EVENTS) {
chunkEvents.shift();
}
notifyChunkStats();
}
function notifyChunkStats() {
if (chunkStatsListeners.size === 0) return;
const stats = getChunkStats();
for (const listener of chunkStatsListeners) {
try {
listener(stats);
} catch (e) {
console.error('[orderAutomat] chunkStatsListener error', e);
}
}
}
export function getChunkStats() {
const total = automat?.state?.total ?? 0;
const chunkCount = total > 0 ? Math.ceil(total / CHUNK_SIZE) : (loadedChunks.size > 0 ? Math.max(...loadedChunks) + 1 : 0);
const items = automat?.state?.items;
let loadedItemsCount = 0;
if (Array.isArray(items)) {
for (let i = 0; i < items.length; i++) {
if (items[i] != null) loadedItemsCount++;
}
}
return {
automatName: 'order',
chunkSize: CHUNK_SIZE,
total,
chunkCount,
loadedChunks: Array.from(loadedChunks).sort((a, b) => a - b),
inflightChunks: Array.from(inflightChunks.keys()).sort((a, b) => a - b),
pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b),
loadedItemsCount,
status: automat?.state?.status ?? 'idle',
queryEpoch,
filter: automat?.state?.filter ?? '',
onlyUnpaid: automat?.state?.onlyUnpaid ?? false,
onlyUndelivered: automat?.state?.onlyUndelivered ?? false,
negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0,
items,
events: [...chunkEvents],
};
}
export function subscribeChunkStats(listener) {
chunkStatsListeners.add(listener);
try {
listener(getChunkStats());
} catch (e) {
console.error(e);
}
return () => {
chunkStatsListeners.delete(listener);
};
}
// Clears pending debounced chunk requests
function clearChunkDebounce() {
if (chunkDebounceTimer) {
clearTimeout(chunkDebounceTimer);
chunkDebounceTimer = null;
}
pendingTargetChunks.clear();
if (pendingTargetChunks.size > 0) {
pendingTargetChunks.clear();
notifyChunkStats();
}
pendingChunkPromise = null;
pendingChunkResolve = null;
pendingChunkReject = null;
@@ -97,6 +175,12 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) {
return Promise.resolve(automat.state.items);
}
recordChunkEvent({
type: 'execute',
chunks: chunkList,
message: `Lade Chunks [${chunkList.join(', ')}] (Epoch ${targetEpoch})`,
});
const promises = [];
for (const c of chunkList) {
promises.push(actions.loadChunk(c, targetEpoch));
@@ -112,6 +196,11 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) {
if (total > 0 && c * CHUNK_SIZE >= total) {
break;
}
recordChunkEvent({
type: 'prefetch',
chunkIndex: c,
message: `Prefetch Chunk #${c}`,
});
void actions.loadChunk(c, targetEpoch);
}
}
@@ -214,6 +303,12 @@ function applyFilterReload() {
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
recordChunkEvent({
type: 'filter_reload',
epoch: queryEpoch,
message: `Filter angewendet (Epoch ${queryEpoch})`,
});
notifyChunkStats();
void actions.loadChunk(0, queryEpoch);
}
@@ -261,6 +356,11 @@ const actions = {
clearChunkDebounce();
const chunks = new Set();
for (let c = start; c <= end; c++) chunks.add(c);
recordChunkEvent({
type: 'require_immediate',
chunks: Array.from(chunks),
message: `Sofortige Anforderung: Chunks ${start}..${end}`,
});
return _executeRequiredChunks(chunks, queryEpoch, prefetch);
}
@@ -282,6 +382,12 @@ const actions = {
for (let c = start; c <= end; c++) {
pendingTargetChunks.add(c);
}
recordChunkEvent({
type: 'pending',
chunks: Array.from(pendingTargetChunks),
message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`,
});
notifyChunkStats();
if (chunkDebounceTimer) {
clearTimeout(chunkDebounceTimer);
@@ -300,6 +406,7 @@ const actions = {
chunkDebounceTimer = null;
const chunksToLoad = new Set(pendingTargetChunks);
pendingTargetChunks.clear();
notifyChunkStats();
const resolve = pendingChunkResolve;
const reject = pendingChunkReject;
@@ -307,6 +414,12 @@ const actions = {
pendingChunkResolve = null;
pendingChunkReject = null;
recordChunkEvent({
type: 'debounce_fire',
chunks: Array.from(chunksToLoad),
message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`,
});
_executeRequiredChunks(chunksToLoad, captureEpoch, true)
.then((res) => resolve?.(res))
.catch((err) => reject?.(err));
@@ -322,9 +435,19 @@ const actions = {
return Promise.resolve(automat.state.items);
}
if (loadedChunks.has(index)) {
recordChunkEvent({
type: 'hit',
chunkIndex: index,
message: `Cache-Hit: Chunk #${index} bereits im Speicher`,
});
return Promise.resolve(automat.state.items);
}
if (inflightChunks.has(index)) {
recordChunkEvent({
type: 'inflight_hit',
chunkIndex: index,
message: `Inflight-Hit: Chunk #${index} lädt bereits`,
});
return inflightChunks.get(index);
}
@@ -334,6 +457,13 @@ const actions = {
automat.setState({ status: 'loading', error: null });
}
const startTime = Date.now();
recordChunkEvent({
type: 'start',
chunkIndex: index,
message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1})`,
});
const promise = fetchOrdersPage(offset, limit)
.then(({ orders, total }) => {
// Discard if active filter query changed while request was in flight
@@ -359,6 +489,7 @@ const actions = {
}
}
const duration = Date.now() - startTime;
loadedChunks.add(index);
automat.setState({
@@ -368,11 +499,27 @@ const actions = {
error: null,
});
recordChunkEvent({
type: 'loaded',
chunkIndex: index,
count: orders.length,
duration,
message: `Chunk #${index} geladen (${orders.length} Aufträge in ${duration}ms)`,
});
return items;
})
.catch((err) => {
if (targetEpoch === queryEpoch) {
const duration = Date.now() - startTime;
console.error(`[orderAutomat] loadChunk(${index}) error:`, err);
recordChunkEvent({
type: 'error',
chunkIndex: index,
duration,
error: err?.message || String(err),
message: `Fehler bei Chunk #${index}: ${err?.message || err}`,
});
if (index === 0 && automat.state.items === null) {
automat.setState({ status: 'failed', error: err });
}
@@ -381,9 +528,11 @@ const actions = {
})
.finally(() => {
inflightChunks.delete(index);
notifyChunkStats();
});
inflightChunks.set(index, promise);
notifyChunkStats();
return promise;
},
@@ -478,6 +627,11 @@ const actions = {
loadedChunks.clear();
inflightChunks.clear();
const count = Array.isArray(items) ? items.length : 0;
recordChunkEvent({
type: 'replace',
message: `Items ersetzt (${count} Einträge)`,
});
notifyChunkStats();
automat.setState({
items,
total: count,
@@ -487,6 +641,63 @@ const actions = {
return items;
},
// MARK: evicts a specific chunk from cache for testing
evictChunk(chunkIndex) {
const index = Math.max(0, Number(chunkIndex) || 0);
if (!loadedChunks.has(index)) return automat.state.items;
loadedChunks.delete(index);
let items = automat.state.items;
if (Array.isArray(items)) {
items = [...items];
const offset = index * CHUNK_SIZE;
for (let i = offset; i < offset + CHUNK_SIZE && i < items.length; i++) {
items[i] = null;
}
automat.setState({ items });
}
recordChunkEvent({
type: 'evict',
chunkIndex: index,
message: `Chunk #${index} aus Cache entfernt (evicted)`,
});
notifyChunkStats();
return automat.state.items;
},
// MARK: clears all loaded chunks from cache
clearCache() {
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
let items = automat.state.items;
if (Array.isArray(items)) {
items = new Array(automat.state.total || 0).fill(null);
automat.setState({ items });
}
recordChunkEvent({
type: 'clear',
message: 'Alle Chunks aus dem Cache entfernt',
});
notifyChunkStats();
return automat.state.items;
},
// MARK: prefetches all remaining chunks
prefetchAllChunks() {
const total = automat.state.total || 0;
const chunkCount = Math.ceil(total / CHUNK_SIZE);
recordChunkEvent({
type: 'prefetch_all',
message: `Starte Prefetch aller ${chunkCount} Chunks`,
});
for (let c = 0; c < chunkCount; c++) {
if (!loadedChunks.has(c) && !inflightChunks.has(c)) {
void actions.loadChunk(c, queryEpoch);
}
}
notifyChunkStats();
},
// MARK: patches an order locally in the automat items cache
patchOrder(id, patch) {
if (!Array.isArray(automat.state.items)) return automat.state.items;
@@ -514,6 +725,11 @@ const actions = {
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
recordChunkEvent({
type: 'reset',
message: 'Order-Automat zurückgesetzt',
});
notifyChunkStats();
automat.setState(initialState);
},
};
@@ -533,6 +749,11 @@ export function requireChunk(chunkIndex, endOrOptions, maybeOptions) {
orderAutomat.getItems = getItems;
orderAutomat.requireChunk = requireChunk;
orderAutomat.CHUNK_SIZE = CHUNK_SIZE;
orderAutomat.getChunkStats = getChunkStats;
orderAutomat.subscribeChunkStats = subscribeChunkStats;
orderAutomat.evictChunk = actions.evictChunk;
orderAutomat.clearCache = actions.clearCache;
orderAutomat.prefetchAllChunks = actions.prefetchAllChunks;
// Reset on logout or company change
let lastCompanyId = null;