oficina / slides Public

fix: replay the deck log when a derived snapshot is unreadable

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 0a51b3bb79bff886854a15c0ed6723f2b2fc3545 parent a863fb6
3 files changed, +91 −3
  • internal/slides/decks.go +11 −1
  • internal/slides/snapshot.go +7 −2
  • internal/slides/snapshot_fallback_test.go +73 −0

CI passed — run details

diff --git a/internal/slides/decks.go b/internal/slides/decks.go
index 851f092..4ce323a 100644
--- a/internal/slides/decks.go
+++ b/internal/slides/decks.go
@@ -203,7 +203,17 @@ func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, e
if snap != nil {
if err := h.Apply(ctx, snap.Bytes); err != nil {
h.Close(ctx)
- return nil, nil, fmt.Errorf("slides: replay deck %s snapshot at seq %d: %w", d.Sqid, snap.Seq, err)
+ a.logger.Error("slides: read a deck's newest snapshot, replaying the plain log instead",
+ "deck", d.Sqid, "err", err)
+ h, err = a.core.NewDoc(ctx, d.Sqid, serverClientID)
+ if err != nil {
+ return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err)
+ }
+ updates, err = a.replayLog(ctx, d, 0)
+ if err != nil {
+ h.Close(ctx)
+ return nil, nil, err
+ }
}
}
for _, u := range updates {
diff --git a/internal/slides/snapshot.go b/internal/slides/snapshot.go
index 7ecda2b..c038012 100644
--- a/internal/slides/snapshot.go
+++ b/internal/slides/snapshot.go
@@ -79,13 +79,18 @@ func (a *app) replayPlan(ctx context.Context, d Deck) (*DeckSnapshot, []DeckUpda
if snap != nil {
since = snap.Seq
}
+ updates, err := a.replayLog(ctx, d, since)
+ return snap, updates, err
+}
+
+func (a *app) replayLog(ctx context.Context, d Deck, since int64) ([]DeckUpdate, error) {
var updates []DeckUpdate
if err := a.db.G.WithContext(ctx).
Where("deck_id = ? AND seq > ?", d.ID, since).
Order("seq").Find(&updates).Error; err != nil {
- return nil, nil, fmt.Errorf("slides: read deck %s log: %w", d.Sqid, err)
+ return nil, fmt.Errorf("slides: read deck %s log: %w", d.Sqid, err)
}
- return snap, updates, nil
+ return updates, nil
}
// maybeSnapshot writes a snapshot when seq lands on the cadence
diff --git a/internal/slides/snapshot_fallback_test.go b/internal/slides/snapshot_fallback_test.go
new file mode 100644
index 0000000..eb845f7
--- /dev/null
+++ b/internal/slides/snapshot_fallback_test.go
@@ -0,0 +1,73 @@
+package slides
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestUnreadableSnapshotFallsBackToAuthoritativeLog(t *testing.T) {
+ db := testDB(t)
+ a, _, err := newApp(db, Config{Env: EnvDevelopment, Logger: quietLogger()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { a.Close() })
+ ctx := context.Background()
+ owner, _, err := ensureUser(ctx, db.G, "snapshot-owner@example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ row, err := a.createDeckRow(ctx, owner, "Preserved deck", defaultTheme)
+ if err != nil {
+ t.Fatal(err)
+ }
+ opening, err := openingOps("Preserved deck", defaultTheme)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := a.editDeck(ctx, &row, actorFor(owner), opening, nil); err != nil {
+ t.Fatal(err)
+ }
+ batch, err := opsJSON([]op{{"op": "set_meta", "title": "Newest log title"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := a.editDeck(ctx, &row, actorFor(owner), batch, nil); err != nil {
+ t.Fatal(err)
+ }
+ want := viewJSON(t, a, row)
+ snapshot := DeckSnapshot{DeckID: row.ID, Seq: 1, Bytes: []byte{0xff}, CreatedAt: time.Now().UTC()}
+ if err := db.G.Create(&snapshot).Error; err != nil {
+ t.Fatal(err)
+ }
+ got := viewJSON(t, a, row)
+ if string(got) != string(want) {
+ t.Fatal("fallback changed the authoritative deck")
+ }
+ var count int64
+ if err := db.G.Model(&DeckUpdate{}).Where("deck_id = ?", row.ID).Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != row.Seq {
+ t.Fatal("fallback changed the update log")
+ }
+ var persisted DeckSnapshot
+ if err := db.G.First(&persisted, snapshot.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if string(persisted.Bytes) != string(snapshot.Bytes) {
+ t.Fatal("read fallback changed the stored snapshot")
+ }
+
+ if err := db.G.Model(&DeckUpdate{}).Where("deck_id = ? AND seq = ?", row.ID, row.Seq).Update("bytes", []byte{0xff}).Error; err != nil {
+ t.Fatal(err)
+ }
+ h, view, err := a.openDeck(ctx, row)
+ if h != nil {
+ h.Close(ctx)
+ }
+ if err == nil || h != nil || view != nil {
+ t.Fatal("fallback concealed corruption in the authoritative log")
+ }
+}