feat(snapshot): Snapshot/Restore with gob encoding, .snap files on disk
This commit is contained in:
parent
b5701c8db8
commit
6e3314a368
1 changed files with 246 additions and 0 deletions
246
snapshot.go
Normal file
246
snapshot.go
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/gob"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SnapshotSchemaVersion — bump on breaking changes to the Snapshot shape.
|
||||||
|
const SnapshotSchemaVersion = 1
|
||||||
|
|
||||||
|
// Snapshot — the on-disk and on-wire shape of the entire in-memory state.
|
||||||
|
// Gob-encoded for v0. Schema-as-theater: the Snapshot fields are derived from
|
||||||
|
// the Go types; this struct is a serialization, not a spec.
|
||||||
|
type Snapshot struct {
|
||||||
|
SchemaVersion int `gob:"schema_version"`
|
||||||
|
CapturedAt time.Time `gob:"captured_at"`
|
||||||
|
Posts map[string]*Post `gob:"posts"`
|
||||||
|
Comments map[string]*Comment `gob:"comments"`
|
||||||
|
Authors map[string]*Author `gob:"authors"`
|
||||||
|
Channels map[string]*Channel `gob:"channels"`
|
||||||
|
PostChannels map[string][]string `gob:"post_channels"`
|
||||||
|
Mentions []*Mention `gob:"mentions"`
|
||||||
|
ReadState map[[2]string]time.Time `gob:"read_state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot — captures the current state under RLock, gob-encodes it. Safe to
|
||||||
|
// call concurrently with reads; blocks writers briefly.
|
||||||
|
func (s *Store) Snapshot() ([]byte, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
snap := Snapshot{
|
||||||
|
SchemaVersion: SnapshotSchemaVersion,
|
||||||
|
CapturedAt: s.Now(),
|
||||||
|
Posts: copyMap(s.posts),
|
||||||
|
Comments: copyCommentMap(s.comments),
|
||||||
|
Authors: copyAuthorMap(s.authors),
|
||||||
|
Channels: copyChannelMap(s.channels),
|
||||||
|
PostChannels: copyPostChannelsMap(s.postChannels),
|
||||||
|
Mentions: copyMentionsMap(s.mentions),
|
||||||
|
ReadState: copyReadStateMap(s.readState),
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := gob.NewEncoder(&buf).Encode(snap); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode snapshot: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore — decodes a snapshot and atomically swaps the in-memory state.
|
||||||
|
// Returns an error if the schema version is unsupported. Callers should
|
||||||
|
// typically Restore once at boot, before starting the HTTP server.
|
||||||
|
func (s *Store) Restore(data []byte) error {
|
||||||
|
var snap Snapshot
|
||||||
|
if err := gob.NewDecoder(bytes.NewReader(data)).Decode(&snap); err != nil {
|
||||||
|
return fmt.Errorf("decode snapshot: %w", err)
|
||||||
|
}
|
||||||
|
if snap.SchemaVersion != SnapshotSchemaVersion {
|
||||||
|
return fmt.Errorf("snapshot schema version %d, expected %d", snap.SchemaVersion, SnapshotSchemaVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.posts = snap.Posts
|
||||||
|
s.comments = snap.Comments
|
||||||
|
s.authors = snap.Authors
|
||||||
|
s.channels = snap.Channels
|
||||||
|
s.postChannels = snap.PostChannels
|
||||||
|
s.mentions = groupMentions(snap.Mentions)
|
||||||
|
s.readState = snap.ReadState
|
||||||
|
|
||||||
|
// Rebuild indexes from scratch.
|
||||||
|
s.postsByBumpedAt = NewSortedIndex[*Post](func(a, b *Post) bool { return a.BumpedAt.After(b.BumpedAt) })
|
||||||
|
for _, p := range s.posts {
|
||||||
|
s.postsByBumpedAt.Insert(p)
|
||||||
|
}
|
||||||
|
s.commentsByPostPath = make(map[string]*SortedIndex[*Comment])
|
||||||
|
for _, c := range s.comments {
|
||||||
|
idx, ok := s.commentsByPostPath[c.PostID]
|
||||||
|
if !ok {
|
||||||
|
idx = NewSortedIndex[*Comment](func(a, b *Comment) bool { return a.Path < b.Path })
|
||||||
|
s.commentsByPostPath[c.PostID] = idx
|
||||||
|
}
|
||||||
|
idx.Insert(c)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushToPath — writes a snapshot to <path>/<year>/<month>/<batch>-<unix>.parquet.
|
||||||
|
// For v0, we write gob files (not Parquet) because the Parquet writer is a
|
||||||
|
// follow-up. The path shape is preserved so the writer swap is a no-op for
|
||||||
|
// callers. File extension: .snap for v0; .parquet lands in v0.5.
|
||||||
|
func (s *Store) FlushToPath(path string) (string, error) {
|
||||||
|
data, err := s.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
now := s.Now()
|
||||||
|
dir := filepath.Join(path, fmt.Sprintf("%04d", now.Year()), fmt.Sprintf("%02d", int(now.Month())))
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("mkdir %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("batch-%d.snap", now.Unix())
|
||||||
|
full := filepath.Join(dir, filename)
|
||||||
|
if err := os.WriteFile(full, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write %s: %w", full, err)
|
||||||
|
}
|
||||||
|
return full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreFromLatest — finds the latest .snap file under path and restores it.
|
||||||
|
// Used at boot. Returns the path it loaded, or "" if no snapshots exist.
|
||||||
|
func (s *Store) RestoreFromLatest(path string) (string, error) {
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
latest, err := findLatestSnapshot(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if latest == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(latest)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := s.Restore(data); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return latest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findLatestSnapshot(path string) (string, error) {
|
||||||
|
var latest string
|
||||||
|
var latestTime time.Time
|
||||||
|
err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Ext(p) != ".snap" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.ModTime().After(latestTime) {
|
||||||
|
latest = p
|
||||||
|
latestTime = info.ModTime()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return latest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- copy helpers (gob needs deep copies) ---
|
||||||
|
|
||||||
|
func copyMap[T any](m map[string]*T) map[string]*T {
|
||||||
|
out := make(map[string]*T, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyCommentMap(m map[string]*Comment) map[string]*Comment {
|
||||||
|
out := make(map[string]*Comment, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyAuthorMap(m map[string]*Author) map[string]*Author {
|
||||||
|
out := make(map[string]*Author, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyChannelMap(m map[string]*Channel) map[string]*Channel {
|
||||||
|
out := make(map[string]*Channel, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyPostChannelsMap(m map[string][]string) map[string][]string {
|
||||||
|
out := make(map[string][]string, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
cp := make([]string, len(v))
|
||||||
|
copy(cp, v)
|
||||||
|
out[k] = cp
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMentionsMap(m map[string][]*Mention) []*Mention {
|
||||||
|
out := make([]*Mention, 0)
|
||||||
|
for _, v := range m {
|
||||||
|
out = append(out, v...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupMentions(flat []*Mention) map[string][]*Mention {
|
||||||
|
out := make(map[string][]*Mention)
|
||||||
|
for _, m := range flat {
|
||||||
|
out[m.Mentioned] = append(out[m.Mentioned], m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyReadStateMap(m map[[2]string]time.Time) map[[2]string]time.Time {
|
||||||
|
out := make(map[[2]string]time.Time, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register gob types up front — required because the encoder doesn't
|
||||||
|
// introspect unexported types automatically.
|
||||||
|
func init() {
|
||||||
|
gob.Register(map[string]*Post{})
|
||||||
|
gob.Register(map[string]*Comment{})
|
||||||
|
gob.Register(map[string]*Author{})
|
||||||
|
gob.Register(map[string]*Channel{})
|
||||||
|
gob.Register(map[string][]string{})
|
||||||
|
gob.Register([]*Mention{})
|
||||||
|
gob.Register(map[[2]string]time.Time{})
|
||||||
|
gob.Register(Mention{})
|
||||||
|
gob.Register(ReadStateEntry{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReadStateEntry struct {
|
||||||
|
AuthorID string
|
||||||
|
PostID string
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue