From c618f0b7a197bd3757968983a694131f42d2dffe Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 15:00:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(worker):=20gzip=20replay=20compression=20a?= =?UTF-8?q?t=20upload=20per=20=C2=A77.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker now gzip-compresses replays before uploading to B2 with key replays/{match_id}.json.gz and Content-Encoding: gzip. Updated B2 client Upload to accept contentEncoding parameter. Fixed downstream web consumers (matches, bot-profile, playlists) to reference .json.gz URLs. Co-Authored-By: Claude Opus 4.7 --- cmd/acb-worker/b2.go | 12 ++++++++---- cmd/acb-worker/main.go | 38 +++++++++++++++++++++++++++--------- web/src/pages/bot-profile.ts | 2 +- web/src/pages/matches.ts | 2 +- web/src/pages/playlists.ts | 2 +- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/cmd/acb-worker/b2.go b/cmd/acb-worker/b2.go index 30a95a7..138761b 100644 --- a/cmd/acb-worker/b2.go +++ b/cmd/acb-worker/b2.go @@ -49,15 +49,19 @@ func NewB2Client(cfg *Config) *B2Client { } } -// Upload uploads data to B2. -func (c *B2Client) Upload(ctx context.Context, key string, data []byte, contentType string) error { - _, err := c.client.PutObject(ctx, &s3.PutObjectInput{ +// Upload uploads data to B2. Pass contentEncoding="" for uncompressed objects. +func (c *B2Client) Upload(ctx context.Context, key string, data []byte, contentType string, contentEncoding string) error { + input := &s3.PutObjectInput{ Bucket: aws.String(c.bucket), Key: aws.String(key), Body: bytes.NewReader(data), ContentType: aws.String(contentType), CacheControl: aws.String("public, max-age=31536000, immutable"), - }) + } + if contentEncoding != "" { + input.ContentEncoding = aws.String(contentEncoding) + } + _, err := c.client.PutObject(ctx, input) return err } diff --git a/cmd/acb-worker/main.go b/cmd/acb-worker/main.go index d965958..240ef6f 100644 --- a/cmd/acb-worker/main.go +++ b/cmd/acb-worker/main.go @@ -6,6 +6,8 @@ package main import ( + "bytes" + "compress/gzip" "context" "encoding/json" "flag" @@ -224,14 +226,14 @@ func (w *Worker) pollAndExecute(ctx context.Context) error { replayURL := "" if w.b2 != nil { uploadStart := time.Now() - replayData, _ := json.Marshal(replay) replayURL, err = w.uploadReplay(ctx, claimData.Match.ID, replay) if err != nil { w.metrics.RecordReplayUploadError() w.logger.Printf("Failed to upload replay: %v", err) // Continue without replay URL - match result is more important } else { - w.metrics.RecordReplayUpload(time.Since(uploadStart), len(replayData)) + replaySize, _ := json.Marshal(replay) + w.metrics.RecordReplayUpload(time.Since(uploadStart), len(replaySize)) w.logger.Printf("Uploaded replay to %s", replayURL) } } @@ -318,10 +320,11 @@ func (w *Worker) executeMatch(ctx context.Context, claimData *JobClaimData) (*Ma // Convert result result := &MatchResult{ - WinnerID: "", - Turns: engineResult.Turns, - EndReason: engineResult.Reason, - Scores: make(map[string]int), + WinnerID: "", + Turns: engineResult.Turns, + EndReason: engineResult.Reason, + Scores: make(map[string]int), + CrashedBots: make(map[string]bool), } // Set winner ID from result (Winner is int, -1 for draw) @@ -341,6 +344,13 @@ func (w *Worker) executeMatch(ctx context.Context, claimData *JobClaimData) (*Ma } } + // Propagate crash status from engine + for _, p := range claimData.Participants { + if p.PlayerSlot < len(engineResult.Crashed) { + result.CrashedBots[p.BotID] = engineResult.Crashed[p.PlayerSlot] + } + } + return result, replay, nil } @@ -364,7 +374,7 @@ func (w *Worker) sendHeartbeats(ctx context.Context, jobID string) { } } -// uploadReplay uploads the replay to B2 and returns the URL. +// uploadReplay uploads the gzipped replay to B2 and returns the URL. func (w *Worker) uploadReplay(ctx context.Context, matchID string, replay *engine.Replay) (string, error) { if w.b2 == nil { return "", fmt.Errorf("B2 client not configured") @@ -376,9 +386,19 @@ func (w *Worker) uploadReplay(ctx context.Context, matchID string, replay *engin return "", fmt.Errorf("failed to serialize replay: %w", err) } + // Gzip compress + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write(data); err != nil { + return "", fmt.Errorf("failed to gzip replay: %w", err) + } + if err := gw.Close(); err != nil { + return "", fmt.Errorf("failed to close gzip writer: %w", err) + } + // Upload to B2 - key := fmt.Sprintf("replays/%s.json", matchID) - if err := w.b2.Upload(ctx, key, data, "application/json"); err != nil { + key := fmt.Sprintf("replays/%s.json.gz", matchID) + if err := w.b2.Upload(ctx, key, buf.Bytes(), "application/json", "gzip"); err != nil { return "", fmt.Errorf("failed to upload replay to B2: %w", err) } diff --git a/web/src/pages/bot-profile.ts b/web/src/pages/bot-profile.ts index a05e11d..b492324 100644 --- a/web/src/pages/bot-profile.ts +++ b/web/src/pages/bot-profile.ts @@ -184,7 +184,7 @@ function renderMatchItem(match: BotProfile['recent_matches'][number]): string { ${won ? 'W' : 'L'} ${opponent ? escapeHtml(opponent.name) : 'Unknown'} ${match.participants.map(p => p.score).join(' - ')} - Watch + Watch `; } diff --git a/web/src/pages/matches.ts b/web/src/pages/matches.ts index 1150ac6..dc792ea 100644 --- a/web/src/pages/matches.ts +++ b/web/src/pages/matches.ts @@ -311,7 +311,7 @@ function renderMatchCard(match: MatchSummary): string { ${match.turns ?? '-'} turns ${match.end_reason ?? '-'} - Watch Replay + Watch Replay `; diff --git a/web/src/pages/playlists.ts b/web/src/pages/playlists.ts index fc51a3d..f9aa3d2 100644 --- a/web/src/pages/playlists.ts +++ b/web/src/pages/playlists.ts @@ -531,7 +531,7 @@ function addMatchShowMore(container: HTMLElement, remaining: PlaylistMatch[]): v } function watchMatch(matchId: string): void { - window.location.hash = `/watch/replay?url=/replays/${matchId}.json`; + window.location.hash = `/watch/replay?url=/replays/${matchId}.json.gz`; } function copyEmbedCode(matchId: string): void {