| 1 | package session |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "sort" |
| 13 | |
| 14 | "reasonix/internal/fileutil" |
| 15 | ) |
| 16 | |
| 17 | const ( |
| 18 | sparseIndexCodec = "reasonix.session.offset-index/v1" |
| 19 | sparseIndexInterval = 256 |
| 20 | sparseIdentityBytes = 4096 |
| 21 | ) |
| 22 | |
| 23 | type sparseIndex struct { |
| 24 | Codec string `json:"codec"` |
| 25 | LogSize int64 `json:"logSize"` |
| 26 | LogModTimeNS int64 `json:"logModTimeNs"` |
| 27 | LogIdentity string `json:"logIdentity"` |
| 28 | LastSequence uint64 `json:"lastSequence"` |
| 29 | CommitCount uint64 `json:"commitCount"` |
| 30 | Entries []sparseIndexEntry `json:"entries"` |
| 31 | partial bool |
| 32 | } |
| 33 | |
| 34 | type sparseIndexEntry struct { |
| 35 | FirstSequence uint64 `json:"firstSequence"` |
| 36 | Offset int64 `json:"offset"` |
| 37 | } |
| 38 | |
| 39 | func sparseIndexPath(cacheDir string) string { |
| 40 | return filepath.Join(cacheDir, "events.offset-index.json") |
| 41 | } |
| 42 | |
| 43 | // loadOrBuildSparseIndex treats the index as an expendable cache. A missing or |
| 44 | // corrupt cache is rebuilt by validating the complete durable log. Failure to |
| 45 | // write the rebuilt cache does not make an otherwise readable session fail. |
| 46 | func loadOrBuildSparseIndex(ctx context.Context, dir, cacheDir string) (sparseIndex, error) { |
| 47 | if err := ctx.Err(); err != nil { |
| 48 | return sparseIndex{}, err |
| 49 | } |
| 50 | manifest, err := readStoredManifest(filepath.Join(dir, "manifest.json")) |
| 51 | if err != nil { |
| 52 | return sparseIndex{}, err |
| 53 | } |
| 54 | logPath := logPathForManifest(dir, manifest) |
| 55 | file, err := os.Open(logPath) |
| 56 | if os.IsNotExist(err) { |
| 57 | return sparseIndex{Codec: sparseIndexCodec, Entries: []sparseIndexEntry{}}, nil |
| 58 | } |
| 59 | if err != nil { |
| 60 | return sparseIndex{}, err |
| 61 | } |
| 62 | defer file.Close() |
| 63 | info, err := file.Stat() |
| 64 | if err != nil { |
| 65 | return sparseIndex{}, err |
| 66 | } |
| 67 | identity, err := sparseLogIdentity(file, info) |
| 68 | if err != nil { |
| 69 | return sparseIndex{}, err |
| 70 | } |
| 71 | if data, readErr := os.ReadFile(sparseIndexPath(cacheDir)); readErr == nil { |
| 72 | var cached sparseIndex |
| 73 | if json.Unmarshal(data, &cached) == nil && cached.validFor(info, identity) { |
| 74 | return cached, nil |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | rebuilt := sparseIndex{ |
| 79 | Codec: sparseIndexCodec, |
| 80 | LogSize: info.Size(), |
| 81 | LogModTimeNS: info.ModTime().UnixNano(), |
| 82 | LogIdentity: identity, |
| 83 | Entries: []sparseIndexEntry{}, |
| 84 | } |
| 85 | commitIndex := 0 |
| 86 | visit := func(offset int64, commit Commit) bool { |
| 87 | if ctx.Err() != nil { |
| 88 | return false |
| 89 | } |
| 90 | if commitIndex%sparseIndexInterval == 0 { |
| 91 | rebuilt.Entries = append(rebuilt.Entries, sparseIndexEntry{FirstSequence: commit.FirstSequence, Offset: offset}) |
| 92 | } |
| 93 | commitIndex++ |
| 94 | rebuilt.CommitCount++ |
| 95 | rebuilt.LastSequence = commit.LastSequence() |
| 96 | return true |
| 97 | } |
| 98 | if manifest.Codec == Codec { |
| 99 | err = scanV4CommitFileRefs(ctx, file, 0, 1, contentStoreForSessionDir(dir), nil, visit) |
| 100 | } else { |
| 101 | err = scanCommitFileCodec(file, 0, 1, manifest.Codec, nil, visit) |
| 102 | } |
| 103 | if err != nil { |
| 104 | return sparseIndex{}, err |
| 105 | } |
| 106 | if err := ctx.Err(); err != nil { |
| 107 | return sparseIndex{}, err |
| 108 | } |
| 109 | writeSparseIndex(cacheDir, rebuilt) |
| 110 | return rebuilt, nil |
| 111 | } |
| 112 | |
| 113 | func (idx sparseIndex) validFor(info os.FileInfo, identity string) bool { |
| 114 | if idx.Codec != sparseIndexCodec || idx.LogSize != info.Size() || idx.LogModTimeNS != info.ModTime().UnixNano() || idx.LogIdentity != identity { |
| 115 | return false |
| 116 | } |
| 117 | var previousSequence uint64 |
| 118 | var previousOffset int64 = -1 |
| 119 | for i, entry := range idx.Entries { |
| 120 | if entry.FirstSequence == 0 || entry.Offset < 0 || entry.Offset >= idx.LogSize || entry.FirstSequence <= previousSequence || entry.Offset <= previousOffset { |
| 121 | return false |
| 122 | } |
| 123 | if i == 0 && (entry.FirstSequence != 1 || entry.Offset != 0) { |
| 124 | return false |
| 125 | } |
| 126 | previousSequence, previousOffset = entry.FirstSequence, entry.Offset |
| 127 | } |
| 128 | return (idx.LastSequence == 0) == (len(idx.Entries) == 0) && (idx.CommitCount == 0) == (idx.LastSequence == 0) |
| 129 | } |
| 130 | |
| 131 | func writeSparseIndex(cacheDir string, index sparseIndex) { |
| 132 | data, err := json.Marshal(index) |
| 133 | if err != nil || os.MkdirAll(cacheDir, 0o700) != nil { |
| 134 | return |
| 135 | } |
| 136 | _ = fileutil.AtomicWriteFileStrict(sparseIndexPath(cacheDir), append(data, '\n'), 0o600) |
| 137 | } |
| 138 | |
| 139 | func (s *Store) recordPersistedIndex(file *os.File, start int64, commits []Commit, lengths []int64) { |
| 140 | if s == nil || file == nil || len(commits) == 0 || len(commits) != len(lengths) { |
| 141 | return |
| 142 | } |
| 143 | info, err := file.Stat() |
| 144 | if err != nil { |
| 145 | return |
| 146 | } |
| 147 | offset := start |
| 148 | for i, commit := range commits { |
| 149 | if i == len(commits)-1 { |
| 150 | s.tip = durableTip{LogOffset: offset + int64(lengths[i]), AnchorOffset: offset, AnchorFirst: commit.FirstSequence, AnchorCommitID: commit.ID, AnchorHash: commit.OperationHash} |
| 151 | } |
| 152 | offset += int64(lengths[i]) |
| 153 | } |
| 154 | identity, err := sparseLogIdentity(file, info) |
| 155 | if err != nil { |
| 156 | return |
| 157 | } |
| 158 | s.indexMu.Lock() |
| 159 | index := s.index |
| 160 | if index.partial { |
| 161 | index.LogSize = info.Size() |
| 162 | index.LogModTimeNS = info.ModTime().UnixNano() |
| 163 | index.LogIdentity = identity |
| 164 | index.LastSequence = commits[len(commits)-1].LastSequence() |
| 165 | s.index = index |
| 166 | s.indexMu.Unlock() |
| 167 | return |
| 168 | } |
| 169 | if index.Codec != sparseIndexCodec || index.LogSize != start { |
| 170 | s.indexMu.Unlock() |
| 171 | _ = s.rebuildWriterIndex(file) |
| 172 | return |
| 173 | } |
| 174 | offset = start |
| 175 | for i, commit := range commits { |
| 176 | if index.CommitCount%sparseIndexInterval == 0 { |
| 177 | index.Entries = append(index.Entries, sparseIndexEntry{FirstSequence: commit.FirstSequence, Offset: offset}) |
| 178 | } |
| 179 | index.CommitCount++ |
| 180 | index.LastSequence = commit.LastSequence() |
| 181 | offset += int64(lengths[i]) |
| 182 | } |
| 183 | index.LogSize = info.Size() |
| 184 | index.LogModTimeNS = info.ModTime().UnixNano() |
| 185 | index.LogIdentity = identity |
| 186 | s.index = index |
| 187 | s.indexMu.Unlock() |
| 188 | writeSparseIndex(s.dir, index) |
| 189 | } |
| 190 | |
| 191 | func (s *Store) rebuildWriterIndex(_ *os.File) error { |
| 192 | index, err := loadOrBuildSparseIndex(context.Background(), s.dir, s.dir) |
| 193 | if err != nil { |
| 194 | return err |
| 195 | } |
| 196 | s.indexMu.Lock() |
| 197 | s.index = index |
| 198 | s.indexMu.Unlock() |
| 199 | return nil |
| 200 | } |
| 201 | |
| 202 | func (idx sparseIndex) checkpoint(offset uint64) sparseIndexEntry { |
| 203 | if len(idx.Entries) == 0 { |
| 204 | return sparseIndexEntry{FirstSequence: 1} |
| 205 | } |
| 206 | target := offset + 1 |
| 207 | position := max(sort.Search(len(idx.Entries), func(i int) bool { return idx.Entries[i].FirstSequence > target })-1, 0) |
| 208 | return idx.Entries[position] |
| 209 | } |
| 210 | |
| 211 | func sparseLogIdentity(file *os.File, info os.FileInfo) (string, error) { |
| 212 | hash := sha256.New() |
| 213 | _, _ = fmt.Fprintf(hash, "%d:%d:", info.Size(), info.ModTime().UnixNano()) |
| 214 | readChunk := func(offset, length int64) error { |
| 215 | if length <= 0 { |
| 216 | return nil |
| 217 | } |
| 218 | buf := make([]byte, length) |
| 219 | n, err := file.ReadAt(buf, offset) |
| 220 | if err != nil && err != io.EOF { |
| 221 | return err |
| 222 | } |
| 223 | _, _ = hash.Write(buf[:n]) |
| 224 | return nil |
| 225 | } |
| 226 | first := min(info.Size(), sparseIdentityBytes) |
| 227 | if err := readChunk(0, first); err != nil { |
| 228 | return "", err |
| 229 | } |
| 230 | if info.Size() > first { |
| 231 | last := min(info.Size()-first, sparseIdentityBytes) |
| 232 | if err := readChunk(info.Size()-last, last); err != nil { |
| 233 | return "", err |
| 234 | } |
| 235 | } |
| 236 | return hex.EncodeToString(hash.Sum(nil)), nil |
| 237 | } |
| 238 |