feat(worker): gzip replay compression at upload per §7.1

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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 15:00:09 -04:00
parent 1451ca5a50
commit c618f0b7a1
5 changed files with 40 additions and 16 deletions

View file

@ -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
}

View file

@ -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)
}

View file

@ -184,7 +184,7 @@ function renderMatchItem(match: BotProfile['recent_matches'][number]): string {
<span class="match-result">${won ? 'W' : 'L'}</span>
<span class="match-opponent">${opponent ? escapeHtml(opponent.name) : 'Unknown'}</span>
<span class="match-score">${match.participants.map(p => p.score).join(' - ')}</span>
<a href="#/watch/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
<a href="#/watch/replay?url=/replays/${match.id}.json.gz" class="btn small">Watch</a>
</div>
`;
}

View file

@ -311,7 +311,7 @@ function renderMatchCard(match: MatchSummary): string {
<span class="match-turns">${match.turns ?? '-'} turns</span>
<span class="match-reason">${match.end_reason ?? '-'}</span>
</div>
<a href="#/watch/replay?url=/replays/${match.id}.json" class="btn small">Watch Replay</a>
<a href="#/watch/replay?url=/replays/${match.id}.json.gz" class="btn small">Watch Replay</a>
</div>
</div>
`;

View file

@ -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 {