oficina / slides Public

fix: replay long deck logs in bounded batches and transient checkpoints

claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 6052b5efc0f33cf15fc9dc95039b5b5001b1ba6e parent 75104a3
3 files changed, +264 −6
  • internal/slides/decks.go +43 −3
  • internal/slides/replay_long_test.go +212 −0
  • internal/slides/snapshot.go +9 −3

CI passed — run details

Intent

Attributed to this commit: 218 g CO₂e (21001220 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on slice-3-export, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.

diff --git a/internal/slides/decks.go b/internal/slides/decks.go
index db24e2d..47fb5b4 100644
--- a/internal/slides/decks.go
+++ b/internal/slides/decks.go
@@ -200,7 +200,11 @@ func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, e
h.Close(ctx)
return nil, nil, err
}
+ var since int64
+ retained := 0
if snap != nil {
+ since = snap.Seq
+ retained = 1
if err := h.Apply(ctx, snap.Bytes); err != nil {
h.Close(ctx)
a.logger.Error("slides: read a deck's newest snapshot, replaying the plain log instead",
@@ -209,6 +213,8 @@ func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, e
if err != nil {
return nil, nil, fmt.Errorf("slides: open deck %s: %w", d.Sqid, err)
}
+ since = 0
+ retained = 0
updates, err = a.replayLog(ctx, d, 0)
if err != nil {
h.Close(ctx)
@@ -216,12 +222,46 @@ func (a *app) openDeck(ctx context.Context, d Deck) (*core.Handle, *deck.Deck, e
}
}
}
- for _, u := range updates {
- if err := h.Apply(ctx, u.Bytes); err != nil {
+ for len(updates) > 0 {
+ for _, u := range updates {
+ if u.Seq != since+1 {
+ h.Close(ctx)
+ return nil, nil, fmt.Errorf("slides: incomplete deck %s log at seq %d", d.Sqid, since+1)
+ }
+ if retained >= replayCheckpointCadence {
+ state, err := h.State(ctx)
+ if err != nil {
+ h.Close(ctx)
+ return nil, nil, fmt.Errorf("slides: checkpoint deck %s replay: %w", d.Sqid, err)
+ }
+ h.Close(ctx)
+ h, err = a.core.NewDoc(ctx, d.Sqid, serverClientID)
+ if err != nil {
+ return nil, nil, err
+ }
+ if err := h.Apply(ctx, state); err != nil {
+ h.Close(ctx)
+ return nil, nil, err
+ }
+ retained = 1
+ }
+ if err := h.Apply(ctx, u.Bytes); err != nil {
+ h.Close(ctx)
+ return nil, nil, fmt.Errorf("slides: replay deck %s update %d: %w", d.Sqid, u.Seq, err)
+ }
+ since = u.Seq
+ retained++
+ }
+ updates, err = a.replayLog(ctx, d, since)
+ if err != nil {
h.Close(ctx)
- return nil, nil, fmt.Errorf("slides: replay deck %s update %d: %w", d.Sqid, u.Seq, err)
+ return nil, nil, err
}
}
+ if since != d.Seq {
+ h.Close(ctx)
+ return nil, nil, fmt.Errorf("slides: incomplete deck %s log at seq %d", d.Sqid, since+1)
+ }
view, err := h.JSON(ctx)
if err != nil {
diff --git a/internal/slides/replay_long_test.go b/internal/slides/replay_long_test.go
new file mode 100644
index 0000000..4229d81
--- /dev/null
+++ b/internal/slides/replay_long_test.go
@@ -0,0 +1,212 @@
+package slides
+
+import (
+ "context"
+ "fmt"
+ "gorm.io/gorm"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/slides/internal/slides/core"
+)
+
+func TestAuthoritativeLogBeyondCoreRetentionLimit(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, "long-log@example.test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ row, err := a.createDeckRow(ctx, owner, "Long history", defaultTheme)
+ if err != nil {
+ t.Fatal(err)
+ }
+ h, err := a.core.NewDoc(ctx, row.Sqid, serverClientID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { h.Close(ctx) }()
+ updates := make([]DeckUpdate, 0, core.MaxRetainedUpdates+19)
+ for seq := int64(1); seq <= core.MaxRetainedUpdates+19; seq++ {
+ if seq%1000 == 0 {
+ state, err := h.State(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ h.Close(ctx)
+ h, err = a.core.NewDoc(ctx, row.Sqid, serverClientID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Apply(ctx, state); err != nil {
+ t.Fatal(err)
+ }
+ }
+ batch, err := opsJSON([]op{{"op": "set_meta", "title": fmt.Sprintf("Revision %d", seq)}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if seq == 1 {
+ batch, err = openingOps("Long history", defaultTheme)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ update, err := h.Ops(ctx, batch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ updates = append(updates, DeckUpdate{DeckID: row.ID, Seq: seq, Actor: actorFor(owner), Bytes: update, CreatedAt: time.Now().UTC()})
+ row.Seq = seq
+ }
+ if err := db.G.CreateInBatches(updates, 128).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.G.Model(&Deck{}).Where("id = ?", row.ID).Update("seq", row.Seq).Error; err != nil {
+ t.Fatal(err)
+ }
+ want, err := h.JSON(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := viewJSON(t, a, row)
+ if string(got) != string(want) {
+ t.Fatal("long replay changed document content")
+ }
+ t.Run("bounded_queries", func(t *testing.T) {
+ queries := 0
+ name := "test:bounded_replay"
+ if err := db.G.Callback().Query().After("gorm:query").Register(name, func(tx *gorm.DB) {
+ if tx.Statement.Table == "deck_updates" {
+ queries++
+ if tx.RowsAffected > 256 {
+ t.Errorf("unbounded database batch: %d rows", tx.RowsAffected)
+ }
+ }
+ }); err != nil {
+ t.Fatal(err)
+ }
+ defer db.G.Callback().Query().Remove(name)
+ if string(viewJSON(t, a, row)) != string(want) {
+ t.Fatal("bounded query changed content")
+ }
+ if queries < 2 {
+ t.Fatal("long log was not paged")
+ }
+ })
+ t.Run("unreadable_cache", func(t *testing.T) {
+ snap := DeckSnapshot{DeckID: row.ID, Seq: 128, Bytes: []byte{0xff}, CreatedAt: time.Now().UTC()}
+ if err := db.G.Create(&snap).Error; err != nil {
+ t.Fatal(err)
+ }
+ defer db.G.Delete(&snap)
+ if string(viewJSON(t, a, row)) != string(want) {
+ t.Fatal("corrupt cache changed long replay")
+ }
+ })
+ t.Run("future_cache_and_update", func(t *testing.T) {
+ batch, err := opsJSON([]op{{"op": "set_meta", "title": "Future revision"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ update, err := h.Ops(ctx, batch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ state, err := h.State(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ future := DeckUpdate{DeckID: row.ID, Seq: row.Seq + 1, Actor: actorFor(owner), Bytes: update, CreatedAt: time.Now().UTC()}
+ if err := db.G.Create(&future).Error; err != nil {
+ t.Fatal(err)
+ }
+ defer db.G.Delete(&future)
+ snap := DeckSnapshot{DeckID: row.ID, Seq: row.Seq + 1, Bytes: state, CreatedAt: time.Now().UTC()}
+ if err := db.G.Create(&snap).Error; err != nil {
+ t.Fatal(err)
+ }
+ defer db.G.Delete(&snap)
+ if string(viewJSON(t, a, row)) != string(want) {
+ t.Fatal("replay included a revision after its captured coordinate")
+ }
+ })
+ for _, missing := range []int64{256, row.Seq} {
+ t.Run(fmt.Sprintf("missing_%d", missing), func(t *testing.T) {
+ u := updates[missing-1]
+ if err := db.G.Delete(&u).Error; err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ if err := db.G.Create(&u).Error; err != nil {
+ t.Error(err)
+ }
+ }()
+ replayed, view, err := a.openDeck(ctx, row)
+ if replayed != nil {
+ replayed.Close(ctx)
+ }
+ if err == nil || replayed != nil || view != nil {
+ t.Fatal("incomplete log returned a partial deck")
+ }
+ })
+ }
+ t.Run("canceled_checkpoint", func(t *testing.T) {
+ cancelCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+ queries := 0
+ name := "test:cancel_checkpoint"
+ if err := db.G.Callback().Query().After("gorm:query").Register(name, func(tx *gorm.DB) {
+ if tx.Statement.Table == "deck_updates" {
+ queries++
+ if queries == 9 {
+ cancel()
+ }
+ }
+ }); err != nil {
+ t.Fatal(err)
+ }
+ defer db.G.Callback().Query().Remove(name)
+ replayed, view, err := a.openDeck(cancelCtx, row)
+ if replayed != nil {
+ replayed.Close(ctx)
+ }
+ if err == nil || replayed != nil || view != nil {
+ t.Fatal("canceled replay returned a partial deck")
+ }
+ if !strings.Contains(err.Error(), "checkpoint") {
+ t.Fatalf("did not reach the canceled State call: %v", err)
+ }
+ })
+ opened, _, err := a.openDeck(ctx, row)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer opened.Close(ctx)
+ batch, err := opsJSON([]op{{"op": "set_meta", "title": "After replay"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := opened.Ops(ctx, batch); err != nil {
+ t.Fatalf("replayed handle cannot accept next edit: %v", err)
+ }
+ 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("read rewrote authoritative history")
+ }
+ if err := db.G.Model(&DeckSnapshot{}).Where("deck_id = ?", row.ID).Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Fatal("read persisted an accelerator snapshot")
+ }
+}
diff --git a/internal/slides/snapshot.go b/internal/slides/snapshot.go
index c038012..04cd3df 100644
--- a/internal/slides/snapshot.go
+++ b/internal/slides/snapshot.go
@@ -33,6 +33,9 @@ import (
// logged at Error for exactly that reason.
const snapshotCadence = 200
+const replayBatchSize = 256
+const replayCheckpointCadence = core.MaxRetainedUpdates / 2
+
// latestSnapshot returns the newest usable snapshot for deckID, or nil
// when there is none.
//
@@ -56,7 +59,7 @@ func (a *app) latestSnapshot(ctx context.Context, deckID int64) (*DeckSnapshot,
}
// replayPlan is what openDeck has to apply to reconstitute d: the
-// newest snapshot (or nil) and every update after it, in seq order.
+// newest eligible snapshot (or nil) and the first bounded update batch.
//
// It is one function rather than two calls at the call site so that the
// invariant tying them together — the tail starts exactly where the
@@ -75,6 +78,9 @@ func (a *app) replayPlan(ctx context.Context, d Deck) (*DeckSnapshot, []DeckUpda
snap = nil
}
+ if snap != nil && (snap.Seq <= 0 || snap.Seq > d.Seq) {
+ snap = nil
+ }
var since int64
if snap != nil {
since = snap.Seq
@@ -86,8 +92,8 @@ func (a *app) replayPlan(ctx context.Context, d Deck) (*DeckSnapshot, []DeckUpda
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 {
+ Where("deck_id = ? AND seq > ? AND seq <= ?", d.ID, since, d.Seq).
+ Order("seq").Limit(replayBatchSize).Find(&updates).Error; err != nil {
return nil, fmt.Errorf("slides: read deck %s log: %w", d.Sqid, err)
}
return updates, nil