From 5036513bf9f6b1f80a5510705db60927b7fbc77b Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Mon, 15 Jun 2026 16:53:50 +0800 Subject: [PATCH] Fix ambiguous empty log cursor handling --- internal/api/handlers/management/logs.go | 34 ++++++++++++++ internal/api/handlers/management/logs_test.go | 47 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go index 6d8477ba..ad15a741 100644 --- a/internal/api/handlers/management/logs.go +++ b/internal/api/handlers/management/logs.go @@ -31,6 +31,12 @@ const ( ) // GetLogs returns log lines with optional incremental loading. +// +// The legacy timestamp path keeps line-count as the total scanned line count for +// compatibility. Cursor and tail reads avoid scanning older files, so line-count +// is the number of returned lines there. A cursor emitted by the legacy path +// points at the latest complete log boundary; combining after with limit is +// therefore tail semantics and does not replay lines trimmed by limit. func (h *Handler) GetLogs(c *gin.Context) { if h == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) @@ -756,6 +762,8 @@ func locateLogCursorFile(files []string, cursor logCursor) (int, bool, error) { if matches && !truncated { if shouldDeferEmptyMainCursorToRotated(files, cursor) { deferEmptyMainMatch = true + } else if shouldResetAmbiguousEmptyMainCursor(files, index, cursor) { + return 0, false, nil } else { return index, true, nil } @@ -804,6 +812,32 @@ func shouldDeferEmptyMainCursorToRotated(files []string, cursor logCursor) bool return false } +func shouldResetAmbiguousEmptyMainCursor(files []string, mainIndex int, cursor logCursor) bool { + if cursor.File != defaultLogFileName || cursor.Offset != 0 || cursor.Size != 0 { + return false + } + info, errStat := os.Stat(files[mainIndex]) + if errStat != nil || info.IsDir() { + return false + } + if info.Size() == cursor.Size && info.ModTime().UnixNano() == cursorModTimeUnixNano(cursor) { + return false + } + for i := range files { + if i == mainIndex || filepath.Base(files[i]) == defaultLogFileName { + continue + } + rotatedInfo, errRotated := os.Stat(files[i]) + if errRotated != nil || rotatedInfo.IsDir() || rotatedInfo.Size() == 0 { + continue + } + if !logFileChangedAfterCursor(files[i], cursor) { + return true + } + } + return false +} + func logFileChangedAfterCursor(path string, cursor logCursor) bool { info, errStat := os.Stat(path) if errStat != nil || info.IsDir() || info.Size() == 0 { diff --git a/internal/api/handlers/management/logs_test.go b/internal/api/handlers/management/logs_test.go index 34a07c7e..f803b2cf 100644 --- a/internal/api/handlers/management/logs_test.go +++ b/internal/api/handlers/management/logs_test.go @@ -474,6 +474,53 @@ func TestGetLogsZeroOffsetCursorWithEmptyFileReadsAcrossRotation(t *testing.T) { } } +func TestGetLogsZeroOffsetCursorWithEmptyFileResetsWhenRotationModTimeAmbiguous(t *testing.T) { + dir := t.TempDir() + mainPath := filepath.Join(dir, defaultLogFileName) + fixedModTime := time.Date(2026, 6, 15, 10, 0, 0, 0, time.Local) + writeMainLog(t, dir, "") + if err := os.Chtimes(mainPath, fixedModTime, fixedModTime); err != nil { + t.Fatalf("set initial main mtime: %v", err) + } + initial := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?limit=1") + if initial.NextCursor == "" { + t.Fatal("initial next-cursor is empty") + } + cursor, errCursor := decodeLogCursor(initial.NextCursor) + if errCursor != nil { + t.Fatalf("decode initial cursor: %v", errCursor) + } + if cursor.Offset != 0 || cursor.Size != 0 { + t.Fatalf("cursor offset/size = %d/%d, want empty zero offset", cursor.Offset, cursor.Size) + } + + first := "[2026-06-15 10:00:01] first" + second := "[2026-06-15 10:00:02] second" + appendMainLog(t, dir, first+"\n") + if err := os.Chtimes(mainPath, fixedModTime, fixedModTime); err != nil { + t.Fatalf("set rotated mtime: %v", err) + } + if err := os.Rename(mainPath, filepath.Join(dir, defaultLogFileName+".1")); err != nil { + t.Fatalf("rotate main log: %v", err) + } + writeMainLog(t, dir, second+"\n") + if err := os.Chtimes(mainPath, fixedModTime, fixedModTime); err != nil { + t.Fatalf("set new main mtime: %v", err) + } + + resp := performGetLogs(t, newLogsTestHandler(dir, true), "/v0/management/logs?cursor="+url.QueryEscape(initial.NextCursor)+"&limit=2") + wantLines := []string{first, second} + if !reflect.DeepEqual(resp.Lines, wantLines) { + t.Fatalf("lines = %#v, want %#v", resp.Lines, wantLines) + } + if !resp.CursorReset { + t.Fatal("cursor-reset = false, want true for ambiguous empty cursor rotation") + } + if resp.LineCount != len(wantLines) { + t.Fatalf("line-count = %d, want returned line count %d", resp.LineCount, len(wantLines)) + } +} + func TestGetLogsInvalidCursorResetsToTail(t *testing.T) { dir := t.TempDir() lines := []string{