Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
26 changes: 22 additions & 4 deletions internal/store/analytics_integrity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_at<? AND rtrim(next_attempt_at,'Z')<=? 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)`
read := func(attempted bool, n int) ([]int, error) {
predicate := "attempts=0"
if attempted {
predicate = "attempts>0"
}
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
}
Expand Down Expand Up @@ -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<? AND rtrim(r.next_attempt_at,'Z')<=? AND (?='' OR r.operation=?)
AND (r.operation<>'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
}
Expand All @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions internal/store/analytics_integrity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading