diff --git a/cli/generate.go b/cli/generate.go index 17eb155..bc0c8c0 100644 --- a/cli/generate.go +++ b/cli/generate.go @@ -98,7 +98,15 @@ func Generate(force bool, debug bool, repoKey string) map[string]interface{} { } repository.Colorschemes = buildRepositoryColorschemes(data, repository.Name) - updateRepositoryAfterGenerate(repository) + if err := updateRepositoryAfterGenerate(repository); err != nil { + log.Printf("Error writing generate data: %s", err) + repositoryErrorCount++ + repositoryErrorSamples = appendRepositoryErrorSample(repositoryErrorSamples, repository, err) + if eventErr := database.CreateRepositoryGenerateErrorEvent(repository.ID, err.Error()); eventErr != nil { + log.Printf("Error creating generate failure event: %s", eventErr) + } + continue + } } cleanUp() @@ -110,10 +118,10 @@ func Generate(force bool, debug bool, repoKey string) map[string]interface{} { } } -func updateRepositoryAfterGenerate(repository repoHelper.Repository) { +func updateRepositoryAfterGenerate(repository repoHelper.Repository) error { log.Printf("Generated %d colorschemes", len(repository.Colorschemes)) data := getGenerateData(repository) - database.UpdateRepositoryFromGenerate(repository.ID, data) + return database.UpdateRepositoryFromGenerate(repository.ID, data) } // Initializes a temporary directory for runtime configuration files diff --git a/internal/database/repository.go b/internal/database/repository.go index cf3f1b7..169f2eb 100644 --- a/internal/database/repository.go +++ b/internal/database/repository.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "log" "strings" "time" @@ -411,101 +412,83 @@ func placeholders(count int) string { } // UpdateRepositoryFromGenerate updates a repository with generate job data. -func UpdateRepositoryFromGenerate(id int64, data GenerateData) { - tx, err := beginWithTransientRetry() - if err != nil { - panic(err) - } - defer func() { - _ = tx.Rollback() - }() - - _, err = tx.Exec("DELETE FROM colorschemes WHERE repository_id = ?", id) - if err != nil { - log.Printf("Error deleting colorschemes: %s", err) - panic(err) - } - - hasDark, hasLight := 0, 0 - for _, scheme := range data.Colorschemes { - if len(scheme.Data.Dark) > 0 { - hasDark = 1 - } - if len(scheme.Data.Light) > 0 { - hasLight = 1 +// The whole transaction retries on transient errors (including Turso 429 +// load-shed). Returns an error so callers can isolate a single repository +// failure instead of aborting the job. +func UpdateRepositoryFromGenerate(id int64, data GenerateData) error { + return runRepositoryWriteTransaction(func(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, "DELETE FROM colorschemes WHERE repository_id = ?", id); err != nil { + return fmt.Errorf("delete colorschemes: %w", err) } - } - _, err = tx.Exec("UPDATE repositories SET has_dark = ?, has_light = ? WHERE id = ?", hasDark, hasLight, id) - if err != nil { - log.Printf("Error updating has_dark/has_light: %s", err) - panic(err) - } - for _, scheme := range data.Colorschemes { - result, err := tx.Exec("INSERT INTO colorschemes (repository_id, name) VALUES (?, ?)", id, scheme.Name) - if err != nil { - log.Printf("Error inserting colorscheme: %s", err) - panic(err) + hasDark, hasLight := 0, 0 + for _, scheme := range data.Colorschemes { + if len(scheme.Data.Dark) > 0 { + hasDark = 1 + } + if len(scheme.Data.Light) > 0 { + hasLight = 1 + } } - schemeID, err := result.LastInsertId() - if err != nil { - panic(err) + if _, err := tx.ExecContext(ctx, "UPDATE repositories SET has_dark = ?, has_light = ? WHERE id = ?", hasDark, hasLight, id); err != nil { + return fmt.Errorf("update has_dark/has_light: %w", err) } - for _, bg := range []struct { - value repository.BackgroundValue - groups []repository.ColorschemeGroup - }{ - {repository.LightBackground, scheme.Data.Light}, - {repository.DarkBackground, scheme.Data.Dark}, - } { - for _, group := range bg.groups { - _, err = tx.Exec(` - INSERT INTO colorscheme_groups ( - colorscheme_id, - background, - name, - hex_code, - bold, - italic, - underline, - undercurl, - underdouble, - underdotted, - underdashed, - strikethrough, - reverse - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - schemeID, - bg.value, - group.Name, - group.HexCode, - group.Bold, - group.Italic, - group.Underline, - group.Undercurl, - group.Underdouble, - group.Underdotted, - group.Underdashed, - group.Strikethrough, - group.Reverse, - ) - if err != nil { - log.Printf("Error inserting colorscheme group: %s", err) - panic(err) + + for _, scheme := range data.Colorschemes { + result, err := tx.ExecContext(ctx, "INSERT INTO colorschemes (repository_id, name) VALUES (?, ?)", id, scheme.Name) + if err != nil { + return fmt.Errorf("insert colorscheme: %w", err) + } + schemeID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("colorscheme last insert id: %w", err) + } + for _, bg := range []struct { + value repository.BackgroundValue + groups []repository.ColorschemeGroup + }{ + {repository.LightBackground, scheme.Data.Light}, + {repository.DarkBackground, scheme.Data.Dark}, + } { + for _, group := range bg.groups { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO colorscheme_groups ( + colorscheme_id, + background, + name, + hex_code, + bold, + italic, + underline, + undercurl, + underdouble, + underdotted, + underdashed, + strikethrough, + reverse + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + schemeID, + bg.value, + group.Name, + group.HexCode, + group.Bold, + group.Italic, + group.Underline, + group.Undercurl, + group.Underdouble, + group.Underdotted, + group.Underdashed, + group.Strikethrough, + group.Reverse, + ); err != nil { + return fmt.Errorf("insert colorscheme group: %w", err) + } } } } - } - err = createRepositoryJobEvent(tx, id, jobGenerate, jobStatusSuccess, "", time.Now().UTC()) - if err != nil { - log.Printf("Error creating repository job event: %s", err) - panic(err) - } - - if err := tx.Commit(); err != nil { - panic(err) - } + return createRepositoryJobEventsContext(ctx, tx, []int64{id}, jobGenerate, jobStatusSuccess, "", time.Now().UTC()) + }) } type repositoryJobEventExecutor interface { diff --git a/internal/database/repository_test.go b/internal/database/repository_test.go index b7886bc..2988f7b 100644 --- a/internal/database/repository_test.go +++ b/internal/database/repository_test.go @@ -792,7 +792,7 @@ func TestUpdateRepositoryFromGenerate(t *testing.T) { setupTestDB(t) insertTestRepo(t, 1, "owner", "repo") - UpdateRepositoryFromGenerate(1, GenerateData{ + if err := UpdateRepositoryFromGenerate(1, GenerateData{ Colorschemes: []repository.Colorscheme{ { Name: "myscheme", @@ -817,7 +817,9 @@ func TestUpdateRepositoryFromGenerate(t *testing.T) { }, }, }, - }) + }); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } repo, err := GetRepository("owner/repo") if err != nil { @@ -850,12 +852,16 @@ func TestUpdateRepositoryFromGenerate(t *testing.T) { setupTestDB(t) insertTestRepo(t, 1, "owner", "repo") - UpdateRepositoryFromGenerate(1, GenerateData{ + if err := UpdateRepositoryFromGenerate(1, GenerateData{ Colorschemes: []repository.Colorscheme{{Name: "scheme1"}}, - }) - UpdateRepositoryFromGenerate(1, GenerateData{ + }); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } + if err := UpdateRepositoryFromGenerate(1, GenerateData{ Colorschemes: []repository.Colorscheme{{Name: "scheme2"}}, - }) + }); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } repo, err := GetRepository("owner/repo") if err != nil { @@ -869,11 +875,53 @@ func TestUpdateRepositoryFromGenerate(t *testing.T) { } }) + t.Run("returns an error and leaves stored colorschemes untouched on failure", func(t *testing.T) { + setupTestDB(t) + insertTestRepo(t, 1, "owner", "repo") + + if err := UpdateRepositoryFromGenerate(1, GenerateData{ + Colorschemes: []repository.Colorscheme{{Name: "scheme1"}}, + }); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } + + if _, err := db.Exec(`DROP TABLE colorscheme_groups`); err != nil { + t.Fatalf("drop colorscheme_groups: %v", err) + } + if err := UpdateRepositoryFromGenerate(1, GenerateData{ + Colorschemes: []repository.Colorscheme{{Name: "scheme2", Data: repository.ColorschemeData{ + Light: []repository.ColorschemeGroup{{Name: "Normal", HexCode: "#ffffff"}}, + }}}, + }); err == nil { + t.Fatal("UpdateRepositoryFromGenerate() = nil, want error") + } + + var name string + err := db.QueryRow(`SELECT name FROM colorschemes WHERE repository_id = 1`).Scan(&name) + if err != nil { + t.Fatalf("query row: %v", err) + } + if name != "scheme1" { + t.Fatalf("Name = %q, want %q", name, "scheme1") + } + + var eventCount int + err = db.QueryRow(`SELECT COUNT(*) FROM repository_job_events WHERE repository_id = 1 AND job = 'generate' AND status = 'success'`).Scan(&eventCount) + if err != nil { + t.Fatalf("query row: %v", err) + } + if eventCount != 1 { + t.Fatalf("eventCount = %d, want 1", eventCount) + } + }) + t.Run("creates a generate job event", func(t *testing.T) { setupTestDB(t) insertTestRepo(t, 1, "owner", "repo") - UpdateRepositoryFromGenerate(1, GenerateData{}) + if err := UpdateRepositoryFromGenerate(1, GenerateData{}); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } var eventCount int err := db.QueryRow(`SELECT COUNT(*) FROM repository_job_events WHERE repository_id = 1 AND job = 'generate' AND status = 'success'`).Scan(&eventCount) @@ -889,8 +937,12 @@ func TestUpdateRepositoryFromGenerate(t *testing.T) { setupTestDB(t) insertTestRepo(t, 1, "owner", "repo") - UpdateRepositoryFromGenerate(1, GenerateData{}) - UpdateRepositoryFromGenerate(1, GenerateData{}) + if err := UpdateRepositoryFromGenerate(1, GenerateData{}); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } + if err := UpdateRepositoryFromGenerate(1, GenerateData{}); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } var eventCount int err := db.QueryRow(`SELECT COUNT(*) FROM repository_job_events WHERE repository_id = 1 AND job = 'generate' AND status = 'success'`).Scan(&eventCount) @@ -935,8 +987,12 @@ func TestUpdateRepositoryFromGenerate(t *testing.T) { if err := CreateRepositoryGenerateErrorEvent(1, "second"); err != nil { t.Fatalf("CreateRepositoryGenerateErrorEvent: %v", err) } - UpdateRepositoryFromGenerate(1, GenerateData{}) - UpdateRepositoryFromGenerate(1, GenerateData{}) + if err := UpdateRepositoryFromGenerate(1, GenerateData{}); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } + if err := UpdateRepositoryFromGenerate(1, GenerateData{}); err != nil { + t.Fatalf("UpdateRepositoryFromGenerate: %v", err) + } var errorCount int err := db.QueryRow(`SELECT COUNT(*) FROM repository_job_events WHERE repository_id = 1 AND job = 'generate' AND status = 'error'`).Scan(&errorCount) diff --git a/internal/database/retry.go b/internal/database/retry.go index a7d3eb6..aec3a7f 100644 --- a/internal/database/retry.go +++ b/internal/database/retry.go @@ -30,6 +30,13 @@ func isTransientLibSQLError(err error) bool { "sqlite_busy", "database is locked", "context deadline exceeded", + "too many requests", + "status=429", + "status 429", + "load-shed", + "load shed", + "rate limit", + "throttle", } { if strings.Contains(errMsg, marker) { return true @@ -122,33 +129,6 @@ func queryWithTransientRetry(query string, args ...any) (*sql.Rows, error) { return nil, lastErr } -func beginWithTransientRetry() (*sql.Tx, error) { - var lastErr error - for attempt := 0; attempt <= transientLibSQLRetryAttempts; attempt++ { - tx, err := db.Begin() - if err == nil { - return tx, nil - } - - if !isTransientLibSQLError(err) { - return nil, err - } - - lastErr = err - if attempt == transientLibSQLRetryAttempts { - break - } - - log.Printf("DB begin failed with transient libsql error, retrying: %s", err) - if pingErr := pingWithTimeout(); pingErr != nil { - return nil, fmt.Errorf("ping before begin retry: %w", pingErr) - } - time.Sleep(transientLibSQLRetryBackoff(attempt)) - } - - return nil, lastErr -} - func pingWithTimeout() error { ctx, cancel := context.WithTimeout(context.Background(), dbOperationTimeout) defer cancel() diff --git a/internal/database/retry_test.go b/internal/database/retry_test.go index 0dc4ba5..74ee1ec 100644 --- a/internal/database/retry_test.go +++ b/internal/database/retry_test.go @@ -37,6 +37,21 @@ func TestIsTransientLibSQLError(t *testing.T) { err: testError("error code = 2: Error executing statement: Hrana: `unexpected response: `Unexpected multiple responses from server"), want: true, }, + { + name: "turso load-shed 429 is transient", + err: testError("error code = 3: Error preparing statement: Hrana: `api error: `status=429 Too Many Requests, body={\"error\":\"load-shed for host vimcolorschemes-reobin.aws-us-east-1.turso.io\"}``"), + want: true, + }, + { + name: "rate limit is transient", + err: testError("rate limit exceeded, retry later"), + want: true, + }, + { + name: "constraint failure mentioning 429 is not transient", + err: testError("UNIQUE constraint failed: duplicate key 429"), + want: false, + }, { name: "constraint failure is not transient", err: testError("UNIQUE constraint failed"),