Reduce log ingest and SCUM page refresh pressure
This commit is contained in:
@@ -41,6 +41,7 @@ type StoreSnapshot struct {
|
|||||||
type runtimeSnapshot struct {
|
type runtimeSnapshot struct {
|
||||||
RunControlSessions []domain.RunControlSession `json:"runControlSessions"`
|
RunControlSessions []domain.RunControlSession `json:"runControlSessions"`
|
||||||
RunEndpoints []domain.RunEndpoint `json:"runEndpoints"`
|
RunEndpoints []domain.RunEndpoint `json:"runEndpoints"`
|
||||||
|
LogStreams []domain.LogStream `json:"logStreams"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileStore struct {
|
type FileStore struct {
|
||||||
@@ -131,7 +132,7 @@ func (store *FileStore) RunUpdateJobs() RunUpdateJobRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileStore) LogStreams() LogStreamRepository {
|
func (store *FileStore) LogStreams() LogStreamRepository {
|
||||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persistRuntime}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *FileStore) MetricSamples() MetricSampleRepository {
|
func (store *FileStore) MetricSamples() MetricSampleRepository {
|
||||||
@@ -318,6 +319,7 @@ func (store *FileStore) runtimeSnapshot() runtimeSnapshot {
|
|||||||
return runtimeSnapshot{
|
return runtimeSnapshot{
|
||||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||||
|
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +352,7 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
|
|||||||
func (store *FileStore) loadRuntimeSnapshot(snapshot runtimeSnapshot) {
|
func (store *FileStore) loadRuntimeSnapshot(snapshot runtimeSnapshot) {
|
||||||
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
||||||
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
|
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
|
||||||
|
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||||
}
|
}
|
||||||
|
|
||||||
type mutableRepository[T any, F any] interface {
|
type mutableRepository[T any, F any] interface {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ func (store *MySQLStore) RunUpdateJobs() RunUpdateJobRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) LogStreams() LogStreamRepository {
|
func (store *MySQLStore) LogStreams() LogStreamRepository {
|
||||||
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist}
|
return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persistRuntime}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *MySQLStore) MetricSamples() MetricSampleRepository {
|
func (store *MySQLStore) MetricSamples() MetricSampleRepository {
|
||||||
@@ -280,6 +280,7 @@ func (store *MySQLStore) runtimeSnapshot() runtimeSnapshot {
|
|||||||
return runtimeSnapshot{
|
return runtimeSnapshot{
|
||||||
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
RunControlSessions: snapshotRepository(store.MemoryStore.runSessions),
|
||||||
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
RunEndpoints: snapshotRepository(store.MemoryStore.runEndpoints),
|
||||||
|
LogStreams: snapshotRepository(store.MemoryStore.logStreams),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,4 +313,5 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
|
|||||||
func (store *MySQLStore) loadRuntimeSnapshot(snapshot runtimeSnapshot) {
|
func (store *MySQLStore) loadRuntimeSnapshot(snapshot runtimeSnapshot) {
|
||||||
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
loadRepository(store.MemoryStore.runSessions, snapshot.RunControlSessions)
|
||||||
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
|
loadRepository(store.MemoryStore.runEndpoints, snapshot.RunEndpoints)
|
||||||
|
loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ func TestFileStorePersistsRunRuntimeStateSeparately(t *testing.T) {
|
|||||||
if err := store.RunControlSessions().Create(session); err != nil {
|
if err := store.RunControlSessions().Create(session); err != nil {
|
||||||
t.Fatalf("create runtime session: %v", err)
|
t.Fatalf("create runtime session: %v", err)
|
||||||
}
|
}
|
||||||
|
stream := domain.LogStream{ID: "run.run-runtime.server-1.scum.console.stdout", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "scum.console.stdout", LatestSeq: 42, StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", CreatedAt: stamp, UpdatedAt: stamp}
|
||||||
|
if err := store.LogStreams().Create(stream); err != nil {
|
||||||
|
t.Fatalf("create runtime log stream: %v", err)
|
||||||
|
}
|
||||||
mainAfter, err := os.ReadFile(path)
|
mainAfter, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read main snapshot after runtime update: %v", err)
|
t.Fatalf("read main snapshot after runtime update: %v", err)
|
||||||
@@ -341,7 +345,7 @@ func TestFileStorePersistsRunRuntimeStateSeparately(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read runtime snapshot: %v", err)
|
t.Fatalf("read runtime snapshot: %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(runtimePayload), endpoint.ID) || strings.Contains(string(runtimePayload), session.SessionToken) {
|
if !strings.Contains(string(runtimePayload), endpoint.ID) || !strings.Contains(string(runtimePayload), stream.ID) || strings.Contains(string(runtimePayload), session.SessionToken) {
|
||||||
t.Fatalf("unexpected runtime snapshot payload: %s", runtimePayload)
|
t.Fatalf("unexpected runtime snapshot payload: %s", runtimePayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +359,9 @@ func TestFileStorePersistsRunRuntimeStateSeparately(t *testing.T) {
|
|||||||
if got, err := reloaded.RunControlSessions().Get(endpoint.ID); err != nil || got.SessionTokenHash != session.SessionTokenHash || got.UsedNonces[0] != "nonce-1" {
|
if got, err := reloaded.RunControlSessions().Get(endpoint.ID); err != nil || got.SessionTokenHash != session.SessionTokenHash || got.UsedNonces[0] != "nonce-1" {
|
||||||
t.Fatalf("runtime session did not reload: %+v err=%v", got, err)
|
t.Fatalf("runtime session did not reload: %+v err=%v", got, err)
|
||||||
}
|
}
|
||||||
|
if got, err := reloaded.LogStreams().Get(stream.ID); err != nil || got.LatestSeq != stream.LatestSeq {
|
||||||
|
t.Fatalf("runtime log stream did not reload: %+v err=%v", got, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
func TestFileStorePersistsPluginOperationsStateAcrossRestart(t *testing.T) {
|
||||||
|
|||||||
@@ -1619,12 +1619,22 @@ func (svc *CoreService) GetRunEndpoint(id string) (domain.RunEndpoint, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) ListRunEndpoints(filter domain.RunEndpointFilter) ([]domain.RunEndpoint, error) {
|
func (svc *CoreService) ListRunEndpoints(filter domain.RunEndpointFilter) ([]domain.RunEndpoint, error) {
|
||||||
svc.controlMu.Lock()
|
endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{})
|
||||||
defer svc.controlMu.Unlock()
|
if err != nil {
|
||||||
if err := svc.sweepExpiredRunRegistrationsLocked(svc.now()); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return svc.store.RunEndpoints().List(filter)
|
stamp := svc.now()
|
||||||
|
items := make([]domain.RunEndpoint, 0, len(endpoints))
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if !runEndpointRegistrationCurrentAt(endpoint, stamp) && (endpoint.Status == domain.RunEndpointStatusOnline || endpoint.Status == domain.RunEndpointStatusDegraded) {
|
||||||
|
endpoint.Status = domain.RunEndpointStatusOffline
|
||||||
|
}
|
||||||
|
if filter.Status != "" && endpoint.Status != filter.Status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, domain.CopyRunEndpoint(endpoint))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (domain.ServerInstance, error) {
|
func (svc *CoreService) CreateServerInstance(instance domain.ServerInstance) (domain.ServerInstance, error) {
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ const queuedQueryBuckets = new Set<string>();
|
|||||||
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
|
export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: string): Promise<SCUMSurfaceData> {
|
||||||
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
|
if (!actions.pluginData) throw new Error("通用 pluginData 能力不可用。");
|
||||||
const canonical = canonicalPageKey(pageKey);
|
const canonical = canonicalPageKey(pageKey);
|
||||||
await queueSCUMDatabaseRefresh(actions, canonical).catch(() => undefined);
|
void queueSCUMDatabaseRefresh(actions, canonical).catch(() => undefined);
|
||||||
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
|
const data: SCUMSurfaceData = { ...emptySCUMSurfaceData };
|
||||||
const keys = pageCollections[canonical];
|
const keys = pageCollections[canonical];
|
||||||
const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const));
|
const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const));
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
|
|||||||
type AttributeDraft = { fieldKey: string; label: string; before: string; after: string };
|
type AttributeDraft = { fieldKey: string; label: string; before: string; after: string };
|
||||||
const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href;
|
const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href;
|
||||||
const scumMapSize = 256;
|
const scumMapSize = 256;
|
||||||
|
const scumSurfaceRefreshMs = 15000;
|
||||||
const rideDistanceThreshold = 50000;
|
const rideDistanceThreshold = 50000;
|
||||||
const vehicleIconByClass: Record<string, string> = {
|
const vehicleIconByClass: Record<string, string> = {
|
||||||
BPC_Barba: new URL("../assets/vehicles/vehicle-BPC_Barba.webp", import.meta.url).href,
|
BPC_Barba: new URL("../assets/vehicles/vehicle-BPC_Barba.webp", import.meta.url).href,
|
||||||
@@ -141,14 +142,14 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
|||||||
if (react.useEffect) react.useEffect(() => {
|
if (react.useEffect) react.useEffect(() => {
|
||||||
if (playerPanel.kind === "closed") refresh();
|
if (playerPanel.kind === "closed") refresh();
|
||||||
if (playerPanel.kind !== "closed") return;
|
if (playerPanel.kind !== "closed") return;
|
||||||
const interval = setInterval(refresh, 5000);
|
const interval = setInterval(refresh, scumSurfaceRefreshMs);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]);
|
}, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]);
|
||||||
|
|
||||||
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
|
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
|
||||||
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
|
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
|
||||||
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
|
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
|
||||||
state.status === "loading" ? e("p", { className: "page-status" }, "正在派发 SCUM 数据库模板查询并读取插件自有集合…") : null,
|
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影,数据库查询会按节流入队…") : null,
|
||||||
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
|
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
|
||||||
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, {
|
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, {
|
||||||
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
|
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ describe("SCUM plugin feature module", () => {
|
|||||||
expect(dataClientSource).not.toContain("logs.query");
|
expect(dataClientSource).not.toContain("logs.query");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not block page data reads on SCUM database refresh dispatch", async () => {
|
||||||
|
const releaseDispatches: Array<(value: { status: "queued" }) => void> = [];
|
||||||
|
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(() => new Promise((resolve) => { releaseDispatches.push(resolve); }));
|
||||||
|
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
|
||||||
|
await expect(loadSCUMSurface({ pluginData: pluginDataActions({ list }), dispatch }, "live-map")).resolves.toMatchObject({ players: expect.any(Array), vehicles: expect.any(Array) });
|
||||||
|
releaseDispatches.forEach((release) => release({ status: "queued" }));
|
||||||
|
});
|
||||||
|
|
||||||
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
|
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
|
||||||
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
|
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
|
||||||
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows");
|
await loadSCUMSurface({ pluginData: pluginDataActions({ list }) }, "workflows");
|
||||||
@@ -308,7 +316,8 @@ describe("SCUM plugin feature module", () => {
|
|||||||
expect(source).not.toContain("projectSCUMLoginLogs");
|
expect(source).not.toContain("projectSCUMLoginLogs");
|
||||||
expect(source).not.toContain("logs.query");
|
expect(source).not.toContain("logs.query");
|
||||||
expect(source).not.toContain("requestSCUMPageQueries");
|
expect(source).not.toContain("requestSCUMPageQueries");
|
||||||
expect(pageSource).toContain("setInterval(refresh, 5000)");
|
expect(pageSource).toContain("scumSurfaceRefreshMs = 15000");
|
||||||
|
expect(pageSource).toContain("setInterval(refresh, scumSurfaceRefreshMs)");
|
||||||
expect(pageSource).toContain("clearInterval(interval)");
|
expect(pageSource).toContain("clearInterval(interval)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user