Workers P2: Feed-Steuerung und Hooks (Zustand 15 s, Ereignisse 5 s)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
97fdfbfcf0
commit
0bfad0ae0b
3 files changed
+630
No files matched your search
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createFeedController } from '../lib/workersFeed'
|
||||
import { createRequestGuard } from '../lib/requestGuard'
|
||||
|
||||
// Spur eines Hustlers wird bei sichtbarem Tab alle 15 s neu geholt
|
||||
const TRACE_MS = 15000
|
||||
const UNKNOWN_ERROR = 'Unbekannter Fehler'
|
||||
|
||||
const isVisible = () => document.visibilityState !== 'hidden'
|
||||
|
||||
// { state, error, loaded, refresh }: Zustand der Hustler (alle 15 s) und Ereignisse (alle 5 s, über onEvents) von der
|
||||
// Dienst-API. state ist null bis zum ersten Erfolg, loaded wird dann wahr und bleibt es. error ist der deutsche Fehlertext
|
||||
// (oder null); die Abfrage läuft bei einem Fehler weiter. Im Hintergrund-Tab ruht alles, beim Zurückkehren wird sofort
|
||||
// abgefragt. refresh() holt den Zustand sofort (z. B. nach dem Pausenschalter). onEvents darf bei jedem Render neu sein.
|
||||
export function useWorkersFeed(api, { onEvents } = {}) {
|
||||
const [state, setState] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const onEventsRef = useRef(onEvents)
|
||||
const controllerRef = useRef(null)
|
||||
|
||||
useEffect(() => { onEventsRef.current = onEvents }, [onEvents])
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
const controller = createFeedController({
|
||||
api,
|
||||
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
||||
clearTimer: (id) => window.clearTimeout(id),
|
||||
isVisible,
|
||||
onSnapshot: (snapshot) => {
|
||||
setState(snapshot)
|
||||
setLoaded(true)
|
||||
},
|
||||
onEvents: (list) => { if (onEventsRef.current) onEventsRef.current(list) },
|
||||
onError: (err) => setError(err ? err.message || UNKNOWN_ERROR : null),
|
||||
})
|
||||
controllerRef.current = controller
|
||||
const onVisibility = () => controller.visibilityChanged()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
controller.start()
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
controller.stop()
|
||||
controllerRef.current = null
|
||||
}
|
||||
}, [api])
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (controllerRef.current) controllerRef.current.refreshState()
|
||||
}, [])
|
||||
|
||||
return { state, error, loaded, refresh }
|
||||
}
|
||||
|
||||
// { trace, loading, error }: Spur eines Hustlers (api.trace). workerId null ergibt { trace: null } ohne Abruf. Beim
|
||||
// Wechsel der workerId wird neu geladen (loading, bis die erste Antwort da ist), danach alle 15 s bei sichtbarem Tab und
|
||||
// sofort beim Zurückkehren. Eine späte Antwort für einen früheren Hustler oder eine ältere Anfrage wird verworfen.
|
||||
export function useWorkerTrace(api, workerId) {
|
||||
const [result, setResult] = useState({ key: null, trace: null, error: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!workerId) return undefined
|
||||
const guard = createRequestGuard()
|
||||
let timer = null
|
||||
|
||||
const stopTimer = () => {
|
||||
if (timer === null) return
|
||||
window.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
stopTimer()
|
||||
const id = guard.next()
|
||||
try {
|
||||
const trace = await api.trace(workerId)
|
||||
if (!guard.isCurrent(id)) return
|
||||
setResult({ key: workerId, trace, error: null })
|
||||
} catch (err) {
|
||||
if (!guard.isCurrent(id)) return
|
||||
// die letzte Spur desselben Hustlers bleibt stehen, der Fehler kommt dazu
|
||||
setResult((prev) => ({ key: workerId, trace: prev.key === workerId ? prev.trace : null, error: err?.message || UNKNOWN_ERROR }))
|
||||
}
|
||||
timer = window.setTimeout(tick, TRACE_MS)
|
||||
}
|
||||
|
||||
// Im Hintergrund kein Abruf, nur neu planen
|
||||
const tick = () => {
|
||||
timer = null
|
||||
if (isVisible()) load()
|
||||
else timer = window.setTimeout(tick, TRACE_MS)
|
||||
}
|
||||
|
||||
const onVisibility = () => { if (isVisible()) load() }
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
load()
|
||||
return () => {
|
||||
guard.next()
|
||||
stopTimer()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [api, workerId])
|
||||
|
||||
const current = Boolean(workerId) && result.key === workerId
|
||||
return {
|
||||
trace: current ? result.trace : null,
|
||||
loading: Boolean(workerId) && !current,
|
||||
error: current ? result.error : null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Polling-Steuerung der Hustler-Seite: Zustand alle 15 s, Ereignisse alle 5 s (Übersicht §6.2).
|
||||
// Kein React, kein Appwrite, kein window/document: Timer und Sichtbarkeit werden injiziert (unter Node testbar).
|
||||
// Je Schleife läuft höchstens ein Abruf und höchstens ein wartender Timer; der nächste Timer wird erst nach
|
||||
// Abschluss des Abrufs gesetzt (kein Intervall, kein Überlappen).
|
||||
import { takeNewEvents, latestTs, needsStateRefresh } from './workersEvents.js'
|
||||
|
||||
const EPOCH = '1970-01-01T00:00:00.000Z'
|
||||
const noop = () => {}
|
||||
|
||||
const isRecord = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
||||
|
||||
/**
|
||||
* Steuerung für den Feed. Rückrufe: onSnapshot(state) bei jedem geholten Zustand, onEvents(liste) einmal je Runde
|
||||
* mit allen neuen Ereignissen (aufsteigend), onError(err) einmal je Fehlerfolge und onError(null) beim nächsten Erfolg.
|
||||
* Der Dienst liefert bei events(after) auch Einträge der letzten 5 s davor; die Deduplizierung übernimmt takeNewEvents.
|
||||
*/
|
||||
export function createFeedController({
|
||||
api, setTimer, clearTimer, isVisible,
|
||||
onSnapshot = noop, onEvents = noop, onError = noop,
|
||||
stateMs = 15000, eventsMs = 5000, pageLimit = 200, maxPages = 5,
|
||||
}) {
|
||||
let running = false
|
||||
let gen = 0 // wird bei start/stop erhöht: Antworten einer früheren Generation ändern nichts
|
||||
let cursor = null // null bis der erste Zustand da ist; danach der jüngste gesehene Zeitstempel
|
||||
let historyMs = NaN // lastEventTs des ersten Zustands: Ereignisse bis dahin sind Vorgeschichte
|
||||
let seen = new Set()
|
||||
let stateAgain = false // refreshState() kam während eines laufenden Zustand-Abrufs
|
||||
let reported = null // zuletzt gemeldete Fehlermeldung (null = kein Fehler)
|
||||
const timer = { state: null, events: null }
|
||||
const inFlight = { state: false, events: false }
|
||||
const failure = { state: null, events: null }
|
||||
|
||||
function cancel(loop) {
|
||||
if (timer[loop] === null) return
|
||||
clearTimer(timer[loop])
|
||||
timer[loop] = null
|
||||
}
|
||||
|
||||
function schedule(loop) {
|
||||
if (!running) return
|
||||
cancel(loop)
|
||||
timer[loop] = setTimer(() => tick(loop), loop === 'state' ? stateMs : eventsMs)
|
||||
}
|
||||
|
||||
// Im Hintergrund kein Abruf, nur neu planen; beim Zurückkehren holt visibilityChanged() sofort nach
|
||||
function tick(loop) {
|
||||
timer[loop] = null
|
||||
if (!running) return
|
||||
if (!isVisible()) {
|
||||
schedule(loop)
|
||||
return
|
||||
}
|
||||
if (loop === 'state') void loadState()
|
||||
else void loadEvents()
|
||||
}
|
||||
|
||||
// Ein Fehler gilt, bis beide Schleifen wieder laufen; gemeldet wird nur, wenn sich der Text ändert
|
||||
function publishError() {
|
||||
const err = failure.state || failure.events || null
|
||||
const message = err ? String(err.message ?? err) : null
|
||||
if (message === reported) return
|
||||
reported = message
|
||||
onError(err)
|
||||
}
|
||||
|
||||
async function loadState() {
|
||||
if (!running || inFlight.state) return
|
||||
cancel('state')
|
||||
inFlight.state = true
|
||||
const myGen = gen
|
||||
let state = null
|
||||
let failed = null
|
||||
try {
|
||||
state = await api.state()
|
||||
} catch (err) {
|
||||
failed = err
|
||||
}
|
||||
if (myGen !== gen) return
|
||||
inFlight.state = false
|
||||
failure.state = failed
|
||||
let first = false
|
||||
try {
|
||||
if (failed === null) {
|
||||
const snapshot = isRecord(state) ? state : {}
|
||||
if (cursor === null) {
|
||||
first = true
|
||||
// fehlt lastEventTs oder ist es kein Datum, gibt es keine Vorgeschichte und der Cursor beginnt bei 1970
|
||||
const lastMs = typeof snapshot.lastEventTs === 'string' ? Date.parse(snapshot.lastEventTs) : NaN
|
||||
cursor = Number.isNaN(lastMs) ? EPOCH : snapshot.lastEventTs
|
||||
historyMs = lastMs
|
||||
}
|
||||
onSnapshot(snapshot)
|
||||
}
|
||||
publishError()
|
||||
} finally {
|
||||
if (myGen === gen) {
|
||||
if (stateAgain) {
|
||||
stateAgain = false
|
||||
void loadState()
|
||||
} else {
|
||||
schedule('state')
|
||||
}
|
||||
if (first) {
|
||||
if (isVisible()) void loadEvents()
|
||||
else schedule('events')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vorgeschichte: in der ersten Runde nach dem Zustand zählen Ereignisse bis lastEventTs als gesehen, aber nicht neu
|
||||
function isHistory(e) {
|
||||
return !Number.isNaN(historyMs) && Date.parse(e.ts) <= historyMs
|
||||
}
|
||||
|
||||
// Holt Seiten, solange sie voll sind (höchstens maxPages); wirft nie, ein Fehler steht im Ergebnis
|
||||
async function fetchRound(myGen) {
|
||||
const fresh = []
|
||||
let failed = null
|
||||
try {
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const res = await api.events(cursor, pageLimit)
|
||||
if (myGen !== gen) break
|
||||
const list = isRecord(res) && Array.isArray(res.events) ? res.events : []
|
||||
for (const e of takeNewEvents(seen, list)) {
|
||||
if (!isHistory(e)) fresh.push(e)
|
||||
}
|
||||
cursor = latestTs(list, cursor)
|
||||
if (list.length < pageLimit) break
|
||||
}
|
||||
} catch (err) {
|
||||
failed = err
|
||||
}
|
||||
return { fresh, failed }
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
if (!running || cursor === null || inFlight.events) return
|
||||
cancel('events')
|
||||
inFlight.events = true
|
||||
const myGen = gen
|
||||
const { fresh, failed } = await fetchRound(myGen)
|
||||
if (myGen !== gen) return
|
||||
inFlight.events = false
|
||||
failure.events = failed
|
||||
if (failed === null) historyMs = NaN
|
||||
try {
|
||||
// Bei einem Fehler nach einer vollen Seite wird das bereits Geholte trotzdem gemeldet (es gilt schon als gesehen)
|
||||
if (fresh.length > 0) onEvents(fresh)
|
||||
publishError()
|
||||
if (needsStateRefresh(fresh)) refreshState()
|
||||
} finally {
|
||||
if (myGen === gen) schedule('events')
|
||||
}
|
||||
}
|
||||
|
||||
function refreshState() {
|
||||
if (!running) return
|
||||
if (inFlight.state) {
|
||||
stateAgain = true
|
||||
return
|
||||
}
|
||||
void loadState()
|
||||
}
|
||||
|
||||
function visibilityChanged() {
|
||||
if (!running || !isVisible()) return
|
||||
void loadState()
|
||||
void loadEvents()
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running) return
|
||||
running = true
|
||||
gen += 1
|
||||
cursor = null
|
||||
historyMs = NaN
|
||||
seen = new Set()
|
||||
stateAgain = false
|
||||
reported = null
|
||||
failure.state = null
|
||||
failure.events = null
|
||||
if (isVisible()) void loadState()
|
||||
else schedule('state')
|
||||
}
|
||||
|
||||
function stop() {
|
||||
running = false
|
||||
gen += 1
|
||||
cancel('state')
|
||||
cancel('events')
|
||||
inFlight.state = false
|
||||
inFlight.events = false
|
||||
stateAgain = false
|
||||
}
|
||||
|
||||
return { start, stop, visibilityChanged, refreshState }
|
||||
}
|
||||
Reference in new issue
Block a user