fix: replay the deck log when a derived snapshot is unreadable
3 files changed,
+91
−3
internal/slides/decks.go+11 −1internal/slides/snapshot.go+7 −2internal/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") |
| + } |
| +} |