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
18 changes: 15 additions & 3 deletions internal/p2p/syncengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -2005,7 +2005,15 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer,
if rootsErr != nil {
mirrorRoots = nil
}
if _, err := snapshot.ZipRoots(game.SavePath, mirrorRoots, zipPath); err != nil {
var captured []store.CapturedFile
if e.Snapshots != nil {
// Unchanged files copied from the newest snapshot, not compressed
// again: seconds rather than minutes for a large save (reuse.go).
_, captured, err = e.Snapshots.ArchiveSaveTo(gameID, game.ActiveBranch, zipPath)
} else {
_, captured, err = snapshot.ZipRootsCapturing(game.SavePath, mirrorRoots, zipPath)
}
if err != nil {
e.Log("warn", fmt.Sprintf("mirror snapshot zip failed: %v", err))
return
}
Expand All @@ -2014,7 +2022,7 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer,
return
}

_ = e.Store.CreateSnapshot(store.Snapshot{
if err := e.Store.CreateSnapshot(store.Snapshot{
ID: remoteSnap.ID,
GameID: gameID,
BranchName: game.ActiveBranch,
Expand All @@ -2023,7 +2031,11 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer,
IsSystemAuto: true,
ZipPath: zipPath,
SizeBytes: info.Size(),
})
}); err == nil {
// What it holds, as for any snapshot: without it the next snapshot
// here could not reuse this one either.
_ = e.Store.RecordSnapshotFiles(remoteSnap.ID, captured)
}
}

// humanBytes formats a byte count as a short human-readable string.
Expand Down
30 changes: 30 additions & 0 deletions internal/p2p/syncengine/mirror_snapshot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package syncengine

import (
"context"
"testing"
)

// The snapshot recorded under the peer's id after a pull records what it
// holds, like any snapshot here: the next snapshot reuses it rather than
// compressing the whole save again.
func TestSync_TheSnapshotAfterAPullRecordsItsFiles(t *testing.T) {
env := setupEngine(t)
write(t, env.remoteDir, "save.dat", "remote progress")
write(t, env.remoteDir, "profile.dat", "remote profile")
env.transport.latestSnap = &SnapshotInfo{ID: "snap_777", Timestamp: "2026-06-01T00:00:00.000Z", Comment: "peer snap"}

if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil {
t.Fatal(err)
}
if _, err := env.store.GetSnapshot("snap_777"); err != nil {
t.Fatalf("mirror snapshot not recorded: %v", err)
}
files, err := env.store.SnapshotFiles("snap_777")
if err != nil {
t.Fatal(err)
}
if len(files) != 2 {
t.Errorf("the snapshot after the pull records %d file(s), want 2: %+v", len(files), files)
}
}
24 changes: 24 additions & 0 deletions internal/snapshot/reuse.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"sync/atomic"

"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/store"
)

// Snapshots that re-pack only what changed.
Expand Down Expand Up @@ -122,3 +123,26 @@ func (r *reuseSource) reusable(filePath, entryName string, info os.FileInfo) (st
}
return prevHash, true
}

// ArchiveSaveTo archives a game's save — every location — into zipPath the way
// a snapshot here is archived: files unchanged since the branch's newest
// snapshot are copied from it rather than compressed again, and what it holds
// is returned for recording. For the snapshot recorded under the other
// device's id after a pull (syncengine recordMirrorSnapshot), which compressed
// the whole save from scratch: a quarter-million-file save sat at "100%" for
// minutes after its download had finished.
func (m *Manager) ArchiveSaveTo(gameID, branch, zipPath string) (skipped []string, captured []store.CapturedFile, err error) {
m.inFlight.Add()
defer m.inFlight.Done()
game, err := m.Store.GetGame(gameID)
if err != nil {
return nil, nil, err
}
extra, rootsErr := m.Store.GameRootPaths(gameID)
if rootsErr != nil {
extra = nil
}
reuse := m.openReuseSource(gameID, branch)
defer reuse.Close()
return zipRootsCapturing(game.SavePath, extra, zipPath, reuse)
}
27 changes: 27 additions & 0 deletions internal/snapshot/reuse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package snapshot

import (
"fmt"
"path/filepath"
"testing"
)

Expand Down Expand Up @@ -67,3 +68,29 @@ func TestSnapshotReusesUnchangedFiles(t *testing.T) {
t.Errorf("second snapshot records %d files, want 51", len(files))
}
}

// The archive written for a snapshot recorded after a pull reuses the
// unchanged files of the branch's newest snapshot too, and says what it holds.
func TestArchiveSaveToReusesUnchangedFiles(t *testing.T) {
env := setup(t)
for i := 0; i < 20; i++ {
write(t, env.saveDir, fmt.Sprintf("map/chunk_%02d.bin", i), fmt.Sprintf("chunk %d ........................", i))
}
if _, err := env.mgr.Create("game1", "before the pull", false); err != nil {
t.Fatal(err)
}
// What a pull wrote: one chunk changed.
write(t, env.saveDir, "map/chunk_05.bin", "from the other device")

zipPath := filepath.Join(t.TempDir(), "mirror.zip")
skipped, captured, err := env.mgr.ArchiveSaveTo("game1", "main", zipPath)
if err != nil || len(skipped) > 0 {
t.Fatalf("archive: skipped=%v err=%v", skipped, err)
}
if got := lastReused.Load(); got != 19 {
t.Errorf("copied %d unchanged entries, want 19", got)
}
if len(captured) != 20 {
t.Errorf("recorded %d files, want 20", len(captured))
}
}
Loading