From f552adc3221a8456b891c09c1d79006bb8712845 Mon Sep 17 00:00:00 2001 From: myuron Date: Fri, 22 May 2026 04:58:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(aws):=20=E7=A9=BA=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=20+=20=E6=96=B0=E3=83=88=E3=83=BC=E3=82=AF=E3=83=B3?= =?UTF-8?q?=E6=99=82=E3=81=AB=20pagination=20=E3=81=8C=E9=80=94=E5=88=87?= =?UTF-8?q?=E3=82=8C=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetLogEvents を時間範囲チャンキングで並列化した v1.4.1 で、 89MB → 15KB と大量にイベントを取りこぼす問題があった。 根本原因: paginateGetLogEvents の終端条件に len(out.Events) == 0 が含まれて おり、AWS が pagination 途中で「空イベント + 新しい NextForwardToken」 を返した時点で end-of-stream と誤判定して打ち切っていた。 AWS CLI ドキュメントは次の通り明記している: > The CloudWatch Logs service will sometimes (especially during periods > of low or no activity) return an empty list of events even if you > have not reached the end of the stream's log events. In this case, > the operation returns nextForwardToken in the response, and you > should call get-log-events again with this token. 時間チャンキングを入れたことで各チャンクがスパースな時間窓を見るよう になり、空ページが頻発して大量のロスが顕在化した。floci は密に返す ためローカルでは再現せず、実 AWS でのみ発現していた。 修正: - len(out.Events) == 0 を終端条件から削除 - nextToken == prevToken のみで終端判定(AWS 仕様準拠) - 既存テスト TestClient_GetLogEvents_PaginationStopsOnEmptyPage を 「空ページを跨いで続行」する新仕様のテストに置き換え Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/aws/client.go | 11 ++++++--- internal/aws/client_test.go | 45 ++++++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/internal/aws/client.go b/internal/aws/client.go index 6418860..6ae30bb 100644 --- a/internal/aws/client.go +++ b/internal/aws/client.go @@ -271,10 +271,15 @@ func (c *Client) paginateGetLogEvents(ctx context.Context, input *cloudwatchlogs }) } - // GetLogEvents signals end-of-stream by returning the same - // NextForwardToken as the previous call, or an empty page. + // CloudWatch Logs signals end-of-stream by returning the same + // NextForwardToken as the previous call. Empty Events with a NEW + // token does NOT mean end — per AWS docs, "the service will + // sometimes (especially during periods of low or no activity) + // return an empty list of events even if you have not reached + // the end of the stream's log events." Breaking on len()==0 + // silently truncates large time-bounded fetches. nextToken := awssdk.ToString(out.NextForwardToken) - if nextToken == "" || nextToken == prevToken || len(out.Events) == 0 { + if nextToken == "" || nextToken == prevToken { break } prevToken = nextToken diff --git a/internal/aws/client_test.go b/internal/aws/client_test.go index 77b5b8b..127c5e4 100644 --- a/internal/aws/client_test.go +++ b/internal/aws/client_test.go @@ -399,25 +399,40 @@ func TestClient_GetLogEvents_PaginationStopsOnSameToken(t *testing.T) { } } -func TestClient_GetLogEvents_PaginationStopsOnEmptyPage(t *testing.T) { - // If a page returns 0 events, pagination should stop even if the token changes. +func TestClient_GetLogEvents_PaginationContinuesPastEmptyPage(t *testing.T) { + // Real CloudWatch Logs sometimes returns an empty Events list with a new + // NextForwardToken during pagination (especially over low-activity time + // windows). Pagination MUST continue and not truncate the result. callCount := 0 mock := &mockLogsAPI{ getLogEventsFn: func(ctx context.Context, params *cloudwatchlogs.GetLogEventsInput, optFns ...func(*cloudwatchlogs.Options)) (*cloudwatchlogs.GetLogEventsOutput, error) { callCount++ - if callCount == 1 { + switch callCount { + case 1: return &cloudwatchlogs.GetLogEventsOutput{ - Events: []types.OutputLogEvent{ - {Timestamp: aws.Int64(1000), Message: aws.String("event-1")}, - }, + Events: []types.OutputLogEvent{{Timestamp: aws.Int64(1000), Message: aws.String("event-1")}}, NextForwardToken: aws.String("token-2"), }, nil + case 2: + // Empty page with a NEW token — must NOT terminate. + return &cloudwatchlogs.GetLogEventsOutput{ + Events: []types.OutputLogEvent{}, + NextForwardToken: aws.String("token-3"), + }, nil + case 3: + return &cloudwatchlogs.GetLogEventsOutput{ + Events: []types.OutputLogEvent{{Timestamp: aws.Int64(3000), Message: aws.String("event-3")}}, + NextForwardToken: aws.String("token-4"), + }, nil + case 4: + // Same token signals end-of-stream. + return &cloudwatchlogs.GetLogEventsOutput{ + Events: []types.OutputLogEvent{}, + NextForwardToken: aws.String("token-4"), + }, nil } - // Second page: empty events - return &cloudwatchlogs.GetLogEventsOutput{ - Events: []types.OutputLogEvent{}, - NextForwardToken: aws.String("token-3"), - }, nil + t.Fatalf("unexpected extra call: %d", callCount) + return nil, nil }, } @@ -427,11 +442,11 @@ func TestClient_GetLogEvents_PaginationStopsOnEmptyPage(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if len(events) != 1 { - t.Fatalf("expected 1 event (stop on empty page), got %d", len(events)) + if len(events) != 2 { + t.Fatalf("expected 2 events across empty intermediate page, got %d", len(events)) } - if callCount != 2 { - t.Errorf("expected 2 API calls, got %d", callCount) + if callCount != 4 { + t.Errorf("expected 4 API calls (continue past empty page, stop on same token), got %d", callCount) } }