Artifact 5a03e77e9336b36fb943033e51047ba3d2d6913e6dee9cdbfa81f159f1df00bb:
- File
internal/oltp/read_test.go
— part of check-in
[c7bf7c08f0]
at
2026-08-25 22:15:11
on branch trunk
— Forum read-state: break the same-second tie, and prove it end to end
Marks were stored second-granular like index post dates, so opening a thread in the same second its newest post landed left the thread the reader was looking at unread. Marks now carry a fractional part, which makes the two orderable: a post from the view's own second was in the page and reads as seen; any later second re-brightens.
Adds a harness test that walks the whole loop, including the part worth pinning — forum.get does not mark read, only forum.read does. (user: dmestas, size: 5833) [annotate] [blame] [check-ins using]
package oltp import ( "testing" "time" ) // The core claim: read is a COMPARISON, not a flag. Marking read is a // point in time, and a reply that lands after it makes the thread // unread again without anyone clearing anything. func TestForumReadIsRelativeToLastReply(t *testing.T) { db := testDB(t) threads := map[string]string{"t1": "2026-01-01T10:00:00"} read, err := db.ForumRead("r", "alice", threads) if err != nil { t.Fatal(err) } if read["t1"] { t.Fatal("a thread nobody has opened must not read as seen") } if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00"); err != nil { t.Fatal(err) } if read, _ = db.ForumRead("r", "alice", threads); !read["t1"] { t.Fatal("after marking, the thread must read as seen") } // A new reply arrives. Nothing touched alice's row — the thread // goes unread because the comparison changed, which is the whole // point of storing a timestamp instead of a boolean. threads["t1"] = "2030-06-01T09:00:00" if read, _ = db.ForumRead("r", "alice", threads); read["t1"] { t.Fatal("a reply newer than the last view must make the thread unread again") } } // Read-state is per account, and anonymous callers have none — the // anonymous list is meant to render every row at full ink. func TestForumReadIsPerAccount(t *testing.T) { db := testDB(t) threads := map[string]string{"t1": "2026-01-01T10:00:00"} if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00"); err != nil { t.Fatal(err) } if read, _ := db.ForumRead("r", "bob", threads); read["t1"] { t.Fatal("alice reading a thread must not mark it read for bob") } read, err := db.ForumRead("r", "", threads) if err != nil { t.Fatal(err) } if len(read) != 0 { t.Fatalf("anonymous read-state = %v, want empty", read) } // Repos are separate namespaces, same as every other oltp table. if read, _ := db.ForumRead("other", "alice", threads); read["t1"] { t.Fatal("read-state must not leak across repos") } } // Opening a thread with nothing new in it must not write. Thread-opens // are the most common act on a forum and SQLite has one writer, so a // re-visit staying a pure read is a property worth pinning. func TestForumReadRevisitDoesNotWrite(t *testing.T) { db := testDB(t) if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00"); err != nil { t.Fatal(err) } var first string if err := db.db.QueryRow(`SELECT at FROM forum_read WHERE repo='r' AND account='alice' AND thread='t1'`).Scan(&first); err != nil { t.Fatal(err) } // Same thread, still nothing newer than the last view. if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00"); err != nil { t.Fatal(err) } var second string if err := db.db.QueryRow(`SELECT at FROM forum_read WHERE repo='r' AND account='alice' AND thread='t1'`).Scan(&second); err != nil { t.Fatal(err) } if second != first { t.Fatalf("re-visit rewrote the row: %q -> %q; the upsert guard should have skipped it", first, second) } } // The watermark sweeps everything at once and takes the rows it // subsumes with it, so the table does not grow as accounts x threads. func TestMarkAllForumReadSweepsAndPrunes(t *testing.T) { db := testDB(t) threads := map[string]string{ "t1": "2026-01-01T10:00:00", "t2": "2026-02-01T10:00:00", "t3": "2026-03-01T10:00:00", } if err := db.MarkForumRead("r", "alice", "t1", threads["t1"]); err != nil { t.Fatal(err) } if err := db.MarkAllForumRead("r", "alice"); err != nil { t.Fatal(err) } read, err := db.ForumRead("r", "alice", threads) if err != nil { t.Fatal(err) } for root := range threads { if !read[root] { t.Fatalf("%s must read as seen after mark-all-read", root) } } var rows int if err := db.db.QueryRow(`SELECT count(*) FROM forum_read WHERE repo='r' AND account='alice'`).Scan(&rows); err != nil { t.Fatal(err) } if rows != 0 { t.Fatalf("per-thread rows below the watermark = %d, want 0 (pruned)", rows) } // The sweep is a point in time, not a permanent silence: a reply // after it still surfaces. threads["t2"] = "2030-06-01T09:00:00" if read, _ = db.ForumRead("r", "alice", threads); read["t2"] { t.Fatal("a reply after the watermark must make the thread unread again") } } // Marks are stored 19-char and index dates may carry a zone suffix, so // the two are compared as strings only after both are clipped. Without // that, a trailing Z would make an OLD post sort after the mark and the // thread would never go quiet. func TestForumReadToleratesLongerTimestamps(t *testing.T) { db := testDB(t) if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00Z"); err != nil { t.Fatal(err) } read, _ := db.ForumRead("r", "alice", map[string]string{"t1": "2026-01-01T10:00:00Z"}) if !read["t1"] { t.Fatal("a zone-suffixed date must not read as newer than the mark that follows it") } } // Opening a thread in the same second its newest post landed must mute // it: that post was in the page the reader was handed. The mark's // fractional part is the only thing that makes the two orderable, so // this is really a test that the widened stamp survives a round trip. func TestForumReadSameSecondAsPostCountsAsRead(t *testing.T) { db := testDB(t) now := time.Now().UTC() posted := now.Format("2006-01-02T15:04:05") if err := db.MarkForumRead("r", "alice", "t1", posted); err != nil { t.Fatal(err) } if read, _ := db.ForumRead("r", "alice", map[string]string{"t1": posted}); !read["t1"] { t.Fatal("a post from the second the reader viewed must count as seen") } // The next second is unambiguously later, and re-brightens. later := now.Add(time.Second).Format("2006-01-02T15:04:05") if read, _ := db.ForumRead("r", "alice", map[string]string{"t1": later}); read["t1"] { t.Fatal("a reply in a later second must make the thread unread") } }