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:
parent
1451ca5a50
commit
c618f0b7a1
5 changed files with 40 additions and 16 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue