| 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") |
| + } |
| +} |