Filter stale batches from live terminal logs
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
const (
|
||||
logEventHeartbeatInterval = 15 * time.Second
|
||||
liveLogSourceClockSkew = 90 * time.Second
|
||||
managedLogSessionIDPrefix = "log-session:"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
liveBoundary := time.Now().UTC().Add(-liveLogSourceClockSkew)
|
||||
instance, streams, liveEligible, subscription, err := h.openLogEventSubscription(r)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
@@ -129,6 +131,12 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if !active.contains(event.Stream) {
|
||||
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) {
|
||||
active.streams = append(active.streams, event.Stream)
|
||||
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 {
|
||||
sessionID string
|
||||
startedAt time.Time
|
||||
|
||||
@@ -146,12 +146,48 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
|
||||
t.Fatalf("ingest during stream snapshot: %v", err)
|
||||
}
|
||||
next := validLogBatchRequest(t, hello.SessionToken, 2, 2)
|
||||
retimestampLogBatchRequest(t, &next, time.Now().UTC().Add(time.Second))
|
||||
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
|
||||
t.Fatalf("ingest next live batch: %v", err)
|
||||
}
|
||||
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) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
@@ -240,6 +276,7 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne
|
||||
nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 21)
|
||||
nextLive.LogSessionID = "session-next"
|
||||
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)
|
||||
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.LogSessionID = "session-next"
|
||||
stderr.SessionStartedAt = nextStartedAt
|
||||
retimestampLogBatchRequest(t, &stderr, time.Now().UTC().Add(time.Second))
|
||||
if _, err := core.IngestLogBatch(stderr.ToDomain()); err != nil {
|
||||
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.LogSessionID = "session-next"
|
||||
nextLive.SessionStartedAt = nextStartedAt
|
||||
retimestampLogBatchRequest(t, &nextLive, time.Now().UTC().Add(time.Second))
|
||||
if _, err := core.IngestLogBatch(nextLive.ToDomain()); err != nil {
|
||||
t.Fatalf("ingest next session live append: %v", err)
|
||||
}
|
||||
@@ -452,3 +491,17 @@ func validLogBatchRequestForStream(t *testing.T, sessionToken string, streamID s
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
- `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.
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user