Filter stale batches from live terminal logs

This commit is contained in:
npc0-hue
2026-08-25 22:34:33 +08:00
parent 7ebe3bb3dd
commit 321bda3f2f
4 changed files with 67 additions and 2 deletions
+12
View File
@@ -14,6 +14,7 @@ import (
const ( const (
logEventHeartbeatInterval = 15 * time.Second logEventHeartbeatInterval = 15 * time.Second
liveLogSourceClockSkew = 90 * time.Second
managedLogSessionIDPrefix = "log-session:" managedLogSessionIDPrefix = "log-session:"
) )
@@ -23,6 +24,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
return return
} }
liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew)
instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r) instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r)
if err != nil { if err != nil {
writeServiceError(w, err) writeServiceError(w, err)
@@ -129,6 +131,12 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
if !active.contains(event.Stream) { if !active.contains(event.Stream) {
continue continue
} }
if !sourceLogEntryIsLive(event.Entry, liveBoundary) {
if event.Entry.Seq > emittedThrough[event.Stream.ID] {
emittedThrough[event.Stream.ID] = event.Entry.Seq
}
continue
}
if !active.hasStream(event.Stream.ID) { if !active.hasStream(event.Stream.ID) {
active.streams = append(active.streams, event.Stream) active.streams = append(active.streams, event.Stream)
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil { if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(event.Stream)); err != nil {
@@ -152,6 +160,10 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
} }
} }
func sourceLogEntryIsLive(entry domain.LogEntry, liveBoundary time.Time) bool {
return !entry.Timestamp.IsZero() && !entry.Timestamp.Before(liveBoundary)
}
type supervisedLogSession struct { type supervisedLogSession struct {
sessionID string sessionID string
startedAt time.Time startedAt time.Time
+53
View File
@@ -146,12 +146,48 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
t.Fatalf("ingest during stream snapshot: %v", err) t.Fatalf("ingest during stream snapshot: %v", err)
} }
next := validLogBatchRequest(t, hello.SessionToken, 2, 2) next := validLogBatchRequest(t, hello.SessionToken, 2, 2)
retimestampLogBatchRequest(t, &next, time.Now().UTC().Add(time.Second))
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil { if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
t.Fatalf("ingest next live batch: %v", err) t.Fatalf("ingest next live batch: %v", err)
} }
assertSSEEvent(t, reader, "log", `"seq":2`) assertSSEEvent(t, reader, "log", `"seq":2`)
} }
func TestLogEventsSSESkipsBufferedBackfillAfterOpen(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx)
streamWriter, streamReader := newSSEPipeResponseWriter()
done := make(chan struct{})
go func() {
router.ServeHTTP(streamWriter, request)
_ = streamWriter.Close()
close(done)
}()
t.Cleanup(func() {
cancel()
_ = streamReader.Close()
<-done
})
if status := <-streamWriter.status; status != http.StatusOK {
t.Fatalf("unexpected SSE status: %d", status)
}
reader := bufio.NewReader(streamReader)
assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`)
assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
stale := validLogBatchRequest(t, hello.SessionToken, 2, 2)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", stale), http.StatusOK)
fresh := validLogBatchRequest(t, hello.SessionToken, 3, 3)
retimestampLogBatchRequest(t, &fresh, time.Now().UTC().Add(time.Second))
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", fresh), http.StatusOK)
assertSSEEvent(t, reader, "log", `"seq":3`)
}
func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) { func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) {
router := newTestRouter() router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router) hello := createLogIngestAPIFixtures(t, router)
@@ -240,6 +276,7 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne
nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 21) nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 21)
nextLive.LogSessionID = "session-next" nextLive.LogSessionID = "session-next"
nextLive.SessionStartedAt = nextStartedAt nextLive.SessionStartedAt = nextStartedAt
retimestampLogBatchRequest(t, &nextLive, time.Now().UTC().Add(time.Second))
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", nextLive), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", nextLive), http.StatusOK)
assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`) assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`)
} }
@@ -298,6 +335,7 @@ func TestLogEventsSSEOrdersSessionSwitchAndAdditionalStreamWithoutDuplicates(t *
stderr := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21) stderr := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21)
stderr.LogSessionID = "session-next" stderr.LogSessionID = "session-next"
stderr.SessionStartedAt = nextStartedAt stderr.SessionStartedAt = nextStartedAt
retimestampLogBatchRequest(t, &stderr, time.Now().UTC().Add(time.Second))
if _, err := core.IngestLogBatch(stderr.ToDomain()); err != nil { if _, err := core.IngestLogBatch(stderr.ToDomain()); err != nil {
t.Fatalf("ingest next session stderr: %v", err) t.Fatalf("ingest next session stderr: %v", err)
} }
@@ -311,6 +349,7 @@ func TestLogEventsSSEOrdersSessionSwitchAndAdditionalStreamWithoutDuplicates(t *
nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 22) nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 22)
nextLive.LogSessionID = "session-next" nextLive.LogSessionID = "session-next"
nextLive.SessionStartedAt = nextStartedAt nextLive.SessionStartedAt = nextStartedAt
retimestampLogBatchRequest(t, &nextLive, time.Now().UTC().Add(time.Second))
if _, err := core.IngestLogBatch(nextLive.ToDomain()); err != nil { if _, err := core.IngestLogBatch(nextLive.ToDomain()); err != nil {
t.Fatalf("ingest next session live append: %v", err) t.Fatalf("ingest next session live append: %v", err)
} }
@@ -452,3 +491,17 @@ func validLogBatchRequestForStream(t *testing.T, sessionToken string, streamID s
Entries: entries, Entries: entries,
} }
} }
func retimestampLogBatchRequest(t *testing.T, request *dto.LogBatchIngestRequest, first time.Time) {
t.Helper()
domainEntries := make([]domain.LogEntry, 0, len(request.Entries))
for index := range request.Entries {
request.Entries[index].Timestamp = first.Add(time.Duration(index) * time.Millisecond).UTC()
domainEntries = append(domainEntries, domain.LogEntry{Seq: request.Entries[index].Seq, Timestamp: request.Entries[index].Timestamp, Level: request.Entries[index].Level, Line: request.Entries[index].Line, Fields: request.Entries[index].Fields, Redacted: request.Entries[index].Redacted})
}
checksum, err := validator.LogEntriesChecksum(domainEntries)
if err != nil {
t.Fatalf("checksum retimestamped entries: %v", err)
}
request.Checksum = checksum
}
+1 -1
View File
@@ -154,7 +154,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests. - `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key. - `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation. - `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows. Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries, and it drops accepted log batches whose source entry timestamps are older than the current SSE connection after a small clock-skew allowance; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
+1 -1
View File
@@ -60,7 +60,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent. - Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent.
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access. Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output only. It does not replay retained log entries; raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients, and internal log ingest and cursor query remain available to platform services and maintenance/debug flows. - Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output only. It does not replay retained log entries, and accepted batches whose source entry timestamps predate the current SSE connection after a small clock-skew allowance stay out of the terminal stream; raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients, and internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
# Client Manager API projection # Client Manager API projection
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client. `PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.