From fd5855579a3fcb010f8322cb99b9821bf03714af Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Oct 2026 23:47:48 -0700 Subject: [PATCH] fix: compare analytics retry deadlines with fractional precision --- CHANGELOG.md | 1 + internal/store/analytics_integrity.go | 26 ++++++++++++--- internal/store/analytics_integrity_test.go | 39 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c49beb2..8bbf9c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ **Highlights:** Reliable local database initialization inside Git worktrees and refreshed dependencies. - Create a missing local database during `init` when the path is inside a Git worktree. Leave an existing database unchanged, and leave a gzip portable source without a raw database file unchanged. Thanks @SebTardif. +- Compare analytics retry deadlines correctly when fractional-second precision differs, so already-due recovery work is not briefly deferred. - Update CrawlKit to v0.17.0 and the ANSI helpers to v0.11.9, retaining the Go 1.27.2 minimum; refresh the Node setup, GoReleaser, app-token, and secret-scanning actions. Thanks @dependabot. - Add an explicit release workflow dry run that builds, signs, and verifies artifacts without publishing. diff --git a/internal/store/analytics_integrity.go b/internal/store/analytics_integrity.go index 9418d23c..2b5ea0be 100644 --- a/internal/store/analytics_integrity.go +++ b/internal/store/analytics_integrity.go @@ -132,13 +132,17 @@ func (s *Store) DueReviewStateWork(ctx context.Context, repository, at string, l if limit <= 0 { return nil, nil } - const eligible = `repository=? AND operation='review_state' AND resolved_at IS NULL AND number>0 AND next_attempt_at<=? AND NOT EXISTS(SELECT 1 FROM analytics_retries core WHERE core.repository=analytics_retries.repository AND core.number=analytics_retries.number AND core.operation='graphql_history' AND core.resolved_at IS NULL)` + before, through, err := analyticsRetryDeadline(at) + if err != nil { + return nil, err + } + const eligible = `repository=? AND operation='review_state' AND resolved_at IS NULL AND number>0 AND next_attempt_at0" } - rows, err := s.q().QueryContext(ctx, "SELECT number FROM analytics_retries WHERE "+eligible+" AND "+predicate+" ORDER BY next_attempt_at,number LIMIT ?", repository, at, n) + rows, err := s.q().QueryContext(ctx, "SELECT number FROM analytics_retries WHERE "+eligible+" AND "+predicate+" ORDER BY next_attempt_at,number LIMIT ?", repository, before, through, n) if err != nil { return nil, err } @@ -191,13 +195,17 @@ func (s *Store) ReviewStateParent(ctx context.Context, repository string, number } func (s *Store) DueAnalyticsRetries(ctx context.Context, repository, at string, limit int, operations ...string) ([]int, error) { + before, through, err := analyticsRetryDeadline(at) + if err != nil { + return nil, err + } operation := "" if len(operations) > 0 { operation = operations[0] } - rows, err := s.q().QueryContext(ctx, `SELECT r.number FROM analytics_retries r WHERE r.repository=? AND r.resolved_at IS NULL AND r.number>0 AND r.next_attempt_at<=? AND (?='' OR r.operation=?) + rows, err := s.q().QueryContext(ctx, `SELECT r.number FROM analytics_retries r WHERE r.repository=? AND r.resolved_at IS NULL AND r.number>0 AND r.next_attempt_at'review_state' OR NOT EXISTS(SELECT 1 FROM analytics_retries core WHERE core.repository=r.repository AND core.number=r.number AND core.operation='graphql_history' AND core.resolved_at IS NULL)) - GROUP BY r.number ORDER BY min(r.next_attempt_at),r.number LIMIT ?`, repository, at, operation, operation, limit) + GROUP BY r.number ORDER BY min(r.next_attempt_at),r.number LIMIT ?`, repository, before, through, operation, operation, limit) if err != nil { return nil, err } @@ -213,6 +221,16 @@ func (s *Store) DueAnalyticsRetries(ctx context.Context, repository, at string, return out, rows.Err() } +func analyticsRetryDeadline(at string) (before, through string, err error) { + deadline, err := time.Parse(time.RFC3339Nano, at) + if err != nil { + return "", "", err + } + deadline = deadline.UTC() + // Keep the indexed range bound; omit Z for exact fractional-second comparisons. + return deadline.Truncate(time.Second).Add(time.Second).Format(time.RFC3339Nano), strings.TrimSuffix(deadline.Format(time.RFC3339Nano), "Z"), nil +} + func (s *Store) AnalyticsOutstanding(ctx context.Context, repository string) (int, error) { var n int err := s.q().QueryRowContext(ctx, `SELECT count(*) FROM analytics_retries WHERE repository=? AND resolved_at IS NULL`, repository).Scan(&n) diff --git a/internal/store/analytics_integrity_test.go b/internal/store/analytics_integrity_test.go index 19d440ab..034a7b8d 100644 --- a/internal/store/analytics_integrity_test.go +++ b/internal/store/analytics_integrity_test.go @@ -7,6 +7,45 @@ import ( "testing" ) +func TestAnalyticsRetriesCompareFractionalDeadlines(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, filepath.Join(t.TempDir(), "archive.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + deadlines := []string{"2026-10-16T06:42:22.348Z", "2026-10-16T06:42:22.3489Z", "2026-10-16T06:42:22.348723Z", "2026-10-16T06:42:22Z"} + for index, deadline := range deadlines { + if _, err := s.DB().Exec("INSERT INTO analytics_retries(repository,number,operation,first_seen_at,last_seen_at,next_attempt_at) VALUES('fixture/repo',?,'review_state',?,?,?)", index+1, deadline, deadline, deadline); err != nil { + t.Fatal(err) + } + } + readers := map[string]func(string) ([]int, error){ + "review": func(at string) ([]int, error) { return s.DueReviewStateWork(ctx, "fixture/repo", at, 10) }, + "core": func(at string) ([]int, error) { + return s.DueAnalyticsRetries(ctx, "fixture/repo", at, 10, "review_state") + }, + } + for name, read := range readers { + t.Run(name, func(t *testing.T) { + for _, at := range []string{"2026-10-16T06:42:22.348723Z", "2026-10-16T08:42:22.348723+02:00"} { + due, err := read(at) + seen := make(map[int]bool) + for _, number := range due { + seen[number] = true + } + if err != nil || len(due) != 3 || !seen[1] || !seen[3] || !seen[4] { + t.Fatalf("due at %s: %v, %v", at, due, err) + } + } + due, err := read("2026-10-16T06:42:22Z") + if err != nil || len(due) != 1 || due[0] != 4 { + t.Fatalf("whole-second deadline: %v, %v", due, err) + } + }) + } +} + func TestAnalyticsFailureRecoveryRetainsReceiptsAndFairRetryTimes(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "archive.db")