-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstore.go
More file actions
1542 lines (1464 loc) · 61.5 KB
/
Copy pathstore.go
File metadata and controls
1542 lines (1464 loc) · 61.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package diskqueue
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/cespare/xxhash/v2"
)
// On-disk format: numbered data files (data.00000001, …), each a 64-byte header
// (magic, cursors/counts, version, header checksum — see the dataFile accessors)
// followed by records (uvarint(len) || payload || xxhash64(payload) as 8 little-
// endian bytes).
//
// Everything recovery needs lives in the header, so it scans records only for a
// segment whose own header proves it was truncated (see surviveCount). Records
// never span files. A global byte offset addresses the stream: file F holds
// offsets [F.base, F.base+F.size). Files are dropped once fully committed — on
// the write that cycles to a new segment, and at the end of a commit. Each record
// and each header carries an xxhash64, verified on read/open to catch corruption.
//
// I/O is plain pread/pwrite/fsync (no mmap): records are written with WriteAt and
// read back with ReadAt into reused buffers, and durability is fsync. Each file's
// 64-byte header is kept resident in memory (dataFile.hdr) and written to its page
// 0 with WriteAt; recovery reads it back with a bare pread.
// store is the raw, []byte-oriented file backend. Not safe for concurrent use;
// the Queue serializes access with its own mutex.
type store struct {
dir string
dirFile *os.File // held open for the whole session: directory fsync + advisory lock
segmentSize int64 // capacity of each file's data region (excludes header)
maxSegments int // max number of data files retained at once; 0 == unbounded
// maxBytes caps the uncommitted backlog in bytes; 0 == unbounded. It is an
// admission policy rather than a geometry, so unlike segmentSize it is not
// baked into anything on disk and can change between opens.
maxBytes int64
noSync bool
syncEvery int // fsync every N writes/commits; <=1 means every one
maxOpenFiles int // cap on simultaneously open segment files; 0 == unbounded
// ioErr latches the first fsync failure (see failIO). Once set, every
// operation that would otherwise claim durability returns it.
ioErr error
files []*dataFile // sorted by num ascending; last is the active write file
nextNum uint64
// diskBytes is the sum of headerSize+capacity over s.files, maintained as files
// are added and reclaimed. Summing it per call made Stats() O(segments) with the
// queue mutex held, which a monitoring scrape pays on every tick.
diskBytes int64
// Intrusive LRU list of currently open files, so touch/evict/remove are O(1)
// pointer splices rather than O(n) slice shifts. lruMRU is the
// most-recently-used end (where touches and new opens go); lruLRU is the
// eviction end. nOpen tracks the length against maxOpenFiles.
lruMRU *dataFile
lruLRU *dataFile
nOpen int
// Staged appends (per-op policy only). A staged record's bytes are written
// past the published extent and tracked here; NOTHING else knows about it —
// df.hdr is untouched (a header write racing the data fsync would publish
// bytes not yet durable), af.size/writeOff/nWritten move only when a span
// publishes, so readers, recovery and Stats never see a staged record.
// pendingBytes/pendingRecs accumulate records waiting for a flush;
// inFlight/inFlightRecs are the span a leader is currently making durable.
// Every staged record lives in the ACTIVE file — cycling quiesces first —
// so a failed publication discards one contiguous tail and nothing more.
pendingBytes int64
pendingRecs int64
inFlight int64
inFlightRecs int64
// flushing is true while a flush leader is between fsyncs with the queue
// lock released; curGroup collects the followers who staged during that
// window (they are covered by the NEXT span and wait on its verdict). A
// solo append leads its own span and allocates no group.
flushing bool
curGroup *flushGroup
// lastFrameAt/lastFrameEnd cache the boundary of the record the most recent
// read crossed. Every consume path is read-then-commit under one lock, so the
// commit walk starts exactly where that read started and would otherwise pread
// the length prefix of a record it just read in full. Only a boundary a read
// established is ever cached, so trusting it costs nothing in integrity: it is
// the same number recordLen would return, without the syscall.
lastFrameAt int64
lastFrameEnd int64
// Reused I/O buffers: writeBuf frames a record before a single WriteAt; readBuf
// holds a block of one segment's data region. Reusing them keeps append/read
// alloc-free once warm; see record.go for what the block cache guarantees.
writeBuf []byte
readBuf []byte
blockFile *dataFile // segment readBuf currently holds bytes from, or nil
blockOff int64 // data-region offset of readBuf[0]
blockLen int // valid bytes in readBuf
writeOff int64 // global offset of the next record to write (tail)
headOff int64 // global offset of the next record to read (in memory only)
commitOff int64 // global offset of the next record to commit (persisted)
// reserved is the ledger of records Reserve handed out that no commit has
// retired, in read order — what lets Ack acknowledge out of order without
// retiring a record another worker still holds. In memory only, like headOff:
// an acknowledgement stranded behind a gap by a crash just replays. See
// reserve.go.
reserved []reservation
nWritten int64 // total records appended
nCommitted int64 // total records committed
unsynced int // writes/commits accumulated since the last batched flush
// unsyncedBytes is record bytes appended but not yet fsync'd — what a power
// loss would cost right now. Only the deferred policies accumulate it: the
// per-op path fsyncs before append returns, so it stays zero there. It is
// cleared only by a flush that covered every file, so a partial failure keeps
// over-reporting rather than under-reporting the exposure.
unsyncedBytes int64
// flushEpoch counts the flushes that ZEROED the pair above (flushBatch's
// success arm). The off-lock flush subtracts its pre-flush snapshot from the
// counters only when the epoch is unchanged: a SyncEvery-boundary flush that
// interleaved has already zeroed them, and subtracting the stale snapshot
// from what accumulated since would book fresh, genuinely-unsynced bytes as
// durable — the under-report the whole gauge is biased against.
flushEpoch uint64
unreclaimed uint64 // failed attempts to unlink a fully-committed segment
// Loss accounting. Corruption is never allowed to wedge the queue, so the
// only way a consumer learns what a bad byte cost is through these: each
// event surfaces as one ErrCorrupt, and these carry the magnitude.
//
// pendingCorrupt is the backlog of losses that happened with no read to
// report them — segments dropped at open, mostly. takeHead pays it down one
// per call so each lost segment reaches the consumer exactly once.
pendingCorrupt int
corruptions uint64
lostBytes uint64
lostRecords uint64
lostSegments uint64
foreignSegments uint64
foreignBytes uint64
discardedBytes uint64
// nHeaderWrites counts writeHeader calls. It exists so a test can assert the
// AMORTIZATION rather than only the observable contract: a per-record append loop
// produces the same records, the same order and the same counts as a staged batch,
// so nothing else distinguishes them.
//
// The model it measures, stated exactly, because the obvious guess is wrong: one
// header write per PUBLISHED SPAN, and a span ends at a segment boundary, at a
// SyncEvery tick, or at the end of the batch. A segment crossing therefore costs
// TWO — the publish that closes the outgoing file, plus createFile's own fresh
// header for the new one. A 1000-record NoSync batch across five 4 KiB segments
// costs 9, not 5; a 200-record batch at SyncEvery=50 in one segment costs 4, not 1.
// Not exported — an operator has no use for it.
nHeaderWrites uint64
nAdded uint64 // records accepted by append
nDelivered uint64 // records handed out by takeHead
nFull uint64 // appends refused with ErrFull
// nCommittedTotal is the lifetime count of records retired by a commit.
// It is deliberately NOT nCommitted, which is a gauge: nCommitted is paired
// with nWritten to compute Count() and is decremented when a fully-committed
// segment is reclaimed, so it falls as the queue does its job and resets on
// reopen. Reporting that as a counter made rate() go negative.
nCommittedTotal uint64
}
func openStore(dir string, segmentSize int64, maxSegments int, noSync bool, syncEvery, maxOpenFiles int) (*store, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
if maxOpenFiles != 0 && maxOpenFiles < 3 {
// Three cursors can each sit in a different segment: the write cursor, the
// read cursor, and the commit cursor that Reserve/Commit leaves behind it.
// A cap of 2 evicts one of them on every operation, so the file the next op
// needs is always the one just closed — reopening a handle per record.
//
// A negative value means the floor, not "unbounded": a caller computing a
// descriptor budget must never get an uncapped queue out of a bad number.
maxOpenFiles = 3
}
s := &store{
dir: dir,
segmentSize: segmentSize,
maxSegments: maxSegments,
noSync: noSync,
syncEvery: syncEvery,
maxOpenFiles: maxOpenFiles,
}
// Hold the directory open for the session: it is both the handle the segment
// creations/removals are fsync'd through and the thing the advisory lock hangs
// on, so no other Queue can write into the same directory.
d, err := os.Open(dir)
if err != nil {
return nil, err
}
s.dirFile = d
if err := tryLockDir(d); err != nil {
_ = d.Close()
return nil, fmt.Errorf("%w: %s", err, dir)
}
if err := s.load(); err != nil {
_ = s.close() // no half-open store: release the handles and the lock
return nil, err
}
return s, nil
}
// failIO latches an unrecoverable durability failure. Only fsync failures land
// here: the kernel reports a writeback error once and then drops the dirty pages,
// so a retry can report success with the data already gone. Rather than claim a
// durability it cannot deliver, the store remembers the first such failure and
// every later append/commit/sync repeats it; the caller's recourse is to close
// and reopen. Write and open failures are *not* latched — they leave the store
// consistent and are safe to retry.
func (s *store) failIO(err error) error {
if err == nil {
return nil
}
if s.ioErr == nil {
s.ioErr = fmt.Errorf("%w: %w", ErrIO, err)
}
return s.ioErr
}
// ensureOpen opens df's file if needed and marks it most-recently-used; the
// active file stays open because every append touches it.
func (s *store) ensureOpen(df *dataFile) error {
if df.f != nil {
s.touchOpen(df)
return nil
}
f, err := os.OpenFile(df.path, os.O_RDWR, 0o644)
if err != nil {
return err
}
df.f = f
s.trackOpen(df)
return nil
}
// trackOpen records df as open (most-recently-used) and evicts down to the cap.
func (s *store) trackOpen(df *dataFile) {
s.lruPushMRU(df)
s.evictOpen(df)
}
// touchOpen moves an already-open df to the most-recently-used end.
func (s *store) touchOpen(df *dataFile) {
if df == s.lruMRU {
return
}
s.lruUnlink(df)
s.lruPushMRU(df)
}
// untrackOpen detaches df from the LRU list (its file is being closed/removed).
func (s *store) untrackOpen(df *dataFile) {
s.lruUnlink(df)
}
// lruPushMRU links df in at the most-recently-used end. df must not already
// be in the list.
func (s *store) lruPushMRU(df *dataFile) {
df.lruPrev = nil
df.lruNext = s.lruMRU
if s.lruMRU != nil {
s.lruMRU.lruPrev = df
} else {
s.lruLRU = df
}
s.lruMRU = df
s.nOpen++
}
// lruUnlink removes df from the LRU list and clears its links.
func (s *store) lruUnlink(df *dataFile) {
if df.lruPrev != nil {
df.lruPrev.lruNext = df.lruNext
} else {
s.lruMRU = df.lruNext
}
if df.lruNext != nil {
df.lruNext.lruPrev = df.lruPrev
} else {
s.lruLRU = df.lruPrev
}
df.lruPrev, df.lruNext = nil, nil
s.nOpen--
}
// evictOpen closes least-recently-used files until at most maxOpenFiles remain
// open, never closing the active file or keep (the one just opened). A dirty
// victim is fsync'd before its handle is closed (a failure there latches ioErr,
// so it is not lost); under noSync nothing is fsync'd and the victim stays
// marked dirty, so a later explicit Sync reopens it and flushes it rather than
// mistaking a closed handle for a clean file.
func (s *store) evictOpen(keep *dataFile) {
if s.maxOpenFiles <= 0 {
return
}
active := s.active()
for s.nOpen > s.maxOpenFiles {
// Walk from the least-recently-used end toward the most-recently-used,
// skipping the active and just-opened files (which are never evicted) and
// any file an off-lock flush has pinned — closing that handle would yank
// it out from under the fdatasync in flight against it.
var victim *dataFile
for df := s.lruLRU; df != nil; df = df.lruPrev {
if df != active && df != keep && df.pins == 0 {
victim = df
break
}
}
if victim == nil {
return // only the active and just-opened files remain
}
if !s.noSync {
// Latches into ioErr on failure; the handle still goes, because
// holding it open would not make the lost writeback reappear.
_ = s.flushFile(victim)
}
_ = victim.f.Close() // read-back errors, if any, are already accounted for
victim.f = nil
s.lruUnlink(victim)
}
}
// live reports whether df is still one of the store's segments — for a caller
// holding a pointer it collected under an earlier acquisition of the lock, like
// the chunked off-lock flush. Reclamation only ever removes a leading PREFIX of
// s.files (see dropCommitted), and segment bases only ever increase, so a file
// has been unlinked exactly when its base sits below the live range: no
// per-file "dropped" flag to keep in step with every removal path. A file
// dropCommitted could not unlink stays in s.files, which only makes this answer
// conservatively "live".
func (s *store) live(df *dataFile) bool {
return len(s.files) > 0 && df.base >= s.files[0].base
}
// flushChunkSize is how many files an off-lock flush (Queue.syncOffLock) may
// hold open at once; 0 means no limit, matching an unbounded maxOpenFiles.
//
// One below the cap, because the chunk's files are pinned and the active file
// is never evictable: leaving that one slot free is what keeps nOpen at or
// under maxOpenFiles for the whole flush rather than one over it whenever the
// active file is not itself in the chunk. openStore floors a nonzero
// maxOpenFiles at 3, so a chunk always holds at least two files.
func (s *store) flushChunkSize() int {
if s.maxOpenFiles <= 0 {
return 0
}
return max(s.maxOpenFiles-1, 1)
}
// batched reports whether the sync policy defers fsync to a periodic flush
// rather than syncing after every write/commit.
func (s *store) batched() bool { return !s.noSync && s.syncEvery > 1 }
// recordOp counts one durable operation (a write or a commit) and flushes every
// segment once syncEvery have accumulated. Used only on the batched path.
func (s *store) recordOp() error { return s.recordOps(1) }
// recordOps is recordOp for n operations at once — a published batch span, whose
// records were each a write even though one header covers them all.
func (s *store) recordOps(n int) error {
s.unsynced += n
if s.unsynced >= s.syncEvery {
return s.flushBatch()
}
return nil
}
// flushBatch fsyncs each dirty file and resets the counter. A torn tail from a
// power loss between flushes is caught by the record checksum. The counter is
// only cleared when every file really flushed, so a failure retries on the next
// operation instead of booking the batch as durable.
//
// It runs UNDER the queue mutex, and the SyncEvery-boundary call from recordOps
// stays there DELIBERATELY — do not route it through the off-lock machinery
// Queue.syncOffLock uses. SyncEvery's contract is a bound: never more than N
// operations unsynced, enforced by the operation that crosses the boundary
// paying the flush before it returns. Detaching that flush from its operation
// lets producers keep writing past the boundary for the duration of the
// fdatasyncs, so the very window the option exists to bound would silently
// widen under load — and the caller who chose SyncEvery chose to pay an
// amortized fsync on the Nth operation. A caller who cannot afford any in-lock
// fsync runs SyncEvery effectively unbounded and paces durability with
// SyncInterval alone (Lumberjack's configuration), and that wall-clock path —
// where the flush belongs to no operation — is exactly the one syncOffLock
// takes off the lock.
func (s *store) flushBatch() error {
var errs error
for _, df := range s.files {
errs = errors.Join(errs, s.flushFile(df)) // keep going; flush what can be flushed
}
if errs != nil {
return errs
}
s.unsynced, s.unsyncedBytes = 0, 0
s.flushEpoch++
return nil
}
func (s *store) filePath(num uint64) string {
return filepath.Join(s.dir, fmt.Sprintf("%s%08d", filePrefix, num))
}
// createFile creates and preallocates segment num. Every failure path unlinks the
// partial file again, so a failed create leaves nothing behind for the next open
// to trip over, and returns cleanly: nothing was published, so the store is
// exactly where it was.
func (s *store) createFile(num uint64, base, capacity int64) (*dataFile, error) {
path := s.filePath(num)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return nil, err
}
fail := func(err error) (*dataFile, error) {
_ = f.Close()
_ = os.Remove(path)
return nil, err
}
// Reserve the blocks now rather than discovering a full filesystem in the
// middle of an append: here the segment is still empty and unreferenced.
if err := preallocate(f, headerSize+capacity); err != nil {
return fail(err)
}
df := &dataFile{num: num, path: path, f: f, hdr: make([]byte, headerSize), base: base, capacity: capacity}
mods := []func(*dataFile){
(*dataFile).initHeader,
setCommitCursor(headerSize),
setWriteCursor(headerSize),
// Geometry, stated by the segment itself: its own capacity, and the
// SegmentSize the store is configured with. File length is not evidence —
// a short file has lost bytes and an oversized file is legitimately long —
// so recovery decides truncation from the capacity field and geometry
// mismatch from the config field, never from os.Stat.
setHdrCapacity(capacity),
setHdrSegSize(s.segmentSize),
}
if capacity > s.segmentSize {
mods = append(mods, setOversized())
}
df.header(mods...)
// Persist the header so a freshly cycled segment is a valid file on disk
// (magic/checksum) even before its first record is written.
if err := s.writeHeader(df); err != nil {
return fail(err)
}
if !s.noSync {
// A full fsync, not datasync: this is the one segment write that changes
// metadata — the file was just created and preallocated — so the inode has
// to reach disk too. Do not "finish the job" by converting this one.
//
// Not latched: the file is about to be unlinked, so nothing durable depends
// on this fsync having happened.
if err := f.Sync(); err != nil {
return fail(err)
}
df.dirty = false
}
// else: writeHeader left it dirty so an explicit Sync flushes the fresh header.
return df, nil
}
func (s *store) active() *dataFile {
if len(s.files) == 0 {
return nil
}
return s.files[len(s.files)-1]
}
// writeHeader writes df's resident header to its page 0 (page cache, not yet
// durable) and marks the file dirty, reopening the file if it was evicted.
func (s *store) writeHeader(df *dataFile) error {
if df.f == nil {
if err := s.ensureOpen(df); err != nil {
return err
}
}
if _, err := df.f.WriteAt(df.hdr, 0); err != nil {
return err
}
s.nHeaderWrites++
df.dirty = true
df.writeSeq++
return nil
}
// flushFile fsyncs df if it has unsynced writes, then marks it clean. No-op for
// an already-clean file; a dirty file whose handle was evicted is reopened,
// because dirty means "has unsynced bytes", not "is open".
//
// A failed fsync latches ioErr: those bytes may be gone for good and a second
// fsync would happily report success. A failure to reopen does not latch — the
// data is still dirty and the next flush retries it.
func (s *store) flushFile(df *dataFile) error {
if !df.dirty {
return nil
}
if df.f == nil {
if err := s.ensureOpen(df); err != nil {
return err
}
}
if err := datasync(df.f); err != nil {
return s.failIO(err)
}
df.dirty = false
return nil
}
// append writes payload as a new record at the tail, cycling to a new file when
// the active one is full.
//
// The record's bytes go down first and the in-memory cursors advance only once
// nothing can still make the record invisible, so a failure before that point
// leaves the store exactly as it was: the caller's error means "not appended".
func (s *store) append(payload []byte) error {
return s.appendRecord(payload, false)
}
// appendRecord is append with an escape hatch: force bypasses BOTH capacity
// caps. Only Requeue uses it. The rotation it implements is backlog-neutral —
// the re-appended copy is followed immediately by the commit that retires the
// head original — so refusing it at the cap inverts its purpose: commits are a
// cursor, records behind the head can never retire first, and a poison head at
// a full queue then wedges the queue precisely when the rotation matters most
// (nothing drains, disk stays pinned, and a restart changes nothing). The
// transient overshoot is bounded by one record, plus at worst one segment.
func (s *store) appendRecord(payload []byte, force bool) error {
if s.ioErr != nil {
return s.ioErr
}
recLen := framedLen(len(payload))
// Two different answers, because they call for different handling. A record
// that cannot fit the cap on an EMPTY queue will never fit, so retrying is
// futile and the caller must drop or split it. One that merely does not fit
// right now is backpressure, and clears as the consumer drains.
if err := s.admitRecord(recLen, force); err != nil {
return err
}
af := s.active()
if s.needsCycle(recLen) {
if err := s.cycle(recLen, force); err != nil {
if errors.Is(err, ErrFull) {
s.nFull++
}
return err
}
af = s.active()
}
// The active file stays open; this also marks it most-recently-used so the
// LRU never evicts it.
if err := s.ensureOpen(af); err != nil {
return err
}
if err := faultPoint("append.writeRecord"); err != nil {
return err
}
werr := s.writeRecord(af, af.size, payload)
// An oversized record grew the frame buffer past anything an ordinary record
// can need; release it (see trimOver for the package-wide policy).
s.writeBuf = trimOver(s.writeBuf, s.segmentSize)
if werr != nil {
return werr // nothing advanced; the bytes are unreferenced and overwritable
}
if s.perOp() {
// Per-op: fsync the record bytes before the header publishes them. Syncing
// the data first guarantees a crash can only ever lose the header update (a
// clean truncation), never leave a published record whose payload never
// landed.
if err := faultPoint("append.syncData"); err != nil {
return s.failIO(err)
}
if err := datasync(af.f); err != nil {
return s.failIO(err)
}
}
af.size += recLen
af.written++
s.writeOff += recLen
s.nWritten++
s.nAdded++
// Update the header (write cursor + count) in memory and publish it.
af.header(
setWriteCursor(headerSize+af.size),
setWrittenCount(af.written),
)
if err := faultPoint("append.writeHeader"); err != nil {
s.rollbackAppend(af, recLen)
return err
}
if err := s.writeHeader(af); err != nil {
// The header never reached even the page cache, so the record is invisible
// to a reopen; roll the in-memory view back to match.
s.rollbackAppend(af, recLen)
return err
}
switch {
case s.noSync:
// No fsync; the record and header sit in the page cache and an explicit
// Sync/Close flushes them.
s.unsyncedBytes += recLen
return nil
case s.batched():
s.unsyncedBytes += recLen
return s.recordOp()
default:
if err := faultPoint("append.syncHeader"); err != nil {
return s.failIO(err)
}
if err := datasync(af.f); err != nil {
// The header is in the page cache, so the record is real to anything
// short of a power loss: it stays, and the store is poisoned.
return s.failIO(err)
}
af.dirty = false
return nil
}
}
// rollbackAppend undoes the in-memory advance for a record whose header never
// reached even the page cache, so the record is invisible to a reopen and the
// store's view matches it. Nothing on disk is touched: the record bytes sit in a
// preallocated region past the write cursor, where the next append overwrites
// them and no reader can address them.
func (s *store) rollbackAppend(af *dataFile, recLen int64) {
af.size -= recLen
af.written--
s.writeOff -= recLen
s.nWritten--
s.nAdded--
af.header(
setWriteCursor(headerSize+af.size),
setWrittenCount(af.written),
)
}
// flushGroup carries the verdict of one flush span to the followers who staged
// records into it while the previous span's fsyncs were in flight. Allocated
// only under actual producer concurrency: a solo append leads its own span,
// waits on nobody, and creates no group — which is what keeps the single-
// producer hot path at zero allocations.
type flushGroup struct {
done chan struct{}
err error
}
// stagedBytes is what has been written but not yet published — the in-flight
// span plus the pending tail behind it.
func (s *store) stagedBytes() int64 { return s.inFlight + s.pendingBytes }
// perOp reports the default durability policy: fsync per operation.
//
// It selects the FSYNCS, not the staging. The staged span machinery serves single
// Add/AddWait under this policy and AddBatch under ALL of them — one header write
// per segment crossed is worth having under NoSync and SyncEvery too — and
// publishBatchLocked consults this only to decide whether the span needs its data
// and header fsyncs. The plain append path is reached by a single Add/AddWait on a
// deferred policy — and by Reader.Requeue on EVERY policy, including this one, since
// its rotation must be synchronous under one continuous lock hold. That second caller
// is why append's per-op fsync arms are live code on the default policy even though
// no Add reaches them.
func (s *store) perOp() bool { return !s.noSync && !s.batched() }
// admitRecord applies the byte cap to a record of framed length recLen,
// counting the staged backlog: staged records occupy real disk and memory, so
// backpressure must see them even though readers cannot yet.
func (s *store) admitRecord(recLen int64, force bool) error {
// A frame this platform cannot index is refused FIRST, before the cycle that would
// otherwise reserve a segment sized to it: the check used to live in writeRecord,
// downstream of createFile, so a refused record still left ~2 GiB fallocate'd as
// the active segment. Not exempted by force — this is a platform limit, not an
// admission policy, and no rotation can make an unindexable frame usable.
if framedTooLarge(recLen, maxFramedLen) {
return fmt.Errorf("%w: framed length %d exceeds this platform's addressable range",
ErrRecordTooLarge, recLen)
}
if !force && s.maxBytes > 0 {
if recLen > s.maxBytes {
return ErrRecordTooLarge
}
if s.size()+s.stagedBytes()+recLen > s.maxBytes {
s.nFull++
return ErrFull
}
}
return nil
}
// needsCycle reports whether a record of framed length recLen fits the active
// file behind everything already staged into it.
func (s *store) needsCycle(recLen int64) bool {
af := s.active()
return af == nil || af.size+s.stagedBytes()+recLen > af.capacity
}
// stagePending writes payload as a record past everything already staged,
// touching no header and no cursor: the record is invisible to readers,
// recovery and Stats until publishSpan moves it into the published extent.
// The caller has already handled admission and cycling; the active file is
// open (every stage path ensures it).
func (s *store) stagePending(payload []byte) error {
af := s.active()
if err := s.ensureOpen(af); err != nil {
return err
}
if err := faultPoint("append.writeRecord"); err != nil {
return err
}
werr := s.writeRecord(af, af.size+s.stagedBytes(), payload)
s.writeBuf = trimOver(s.writeBuf, s.segmentSize)
if werr != nil {
return werr // nothing tracked; the bytes are unreferenced and overwritable
}
s.pendingBytes += framedLen(len(payload))
s.pendingRecs++
return nil
}
// takeSpan moves the pending records into the in-flight span a leader is about
// to make durable. The previous span must have settled (published or
// discarded) first; the leader loop guarantees it.
func (s *store) takeSpan() {
s.inFlight, s.inFlightRecs = s.pendingBytes, s.pendingRecs
s.pendingBytes, s.pendingRecs = 0, 0
}
// publishSpan moves the in-flight span into the published extent and writes
// the header that publishes it — data-before-header holds span-wide, because
// the caller fsync'd the span's bytes before calling. A failed header write
// discards the span AND the pending tail staged behind it: the two are one
// contiguous run past the (unchanged) published cursor, which is exactly what
// the quiesce-before-cycle rule buys, so the store's view snaps back to the
// header that never changed and the next stage overwrites the dead bytes.
func (s *store) publishSpan() error {
af := s.active()
af.size += s.inFlight
af.written += s.inFlightRecs
s.writeOff += s.inFlight
s.nWritten += s.inFlightRecs
s.nAdded += uint64(s.inFlightRecs)
af.header(
setWriteCursor(headerSize+af.size),
setWrittenCount(af.written),
)
if err := faultPoint("append.writeHeader"); err != nil {
s.unpublishSpan(af)
return err
}
if err := s.writeHeader(af); err != nil {
s.unpublishSpan(af)
return err
}
s.inFlight, s.inFlightRecs = 0, 0
return nil
}
// unpublishSpan reverses publishSpan's in-memory advance after a failed header
// write and discards everything staged. Nothing on disk moved: the header
// bytes never left df.hdr, and the record bytes sit past the write cursor
// where nothing can address them.
func (s *store) unpublishSpan(af *dataFile) {
af.size -= s.inFlight
af.written -= s.inFlightRecs
s.writeOff -= s.inFlight
s.nWritten -= s.inFlightRecs
s.nAdded -= uint64(s.inFlightRecs)
af.header(
setWriteCursor(headerSize+af.size),
setWrittenCount(af.written),
)
s.discardStaged()
}
// discardStaged forgets every staged-but-unpublished record — the response to
// any failure that makes the staged tail unpublishable (a failed data fsync, a
// failed header write). Their bytes lie past the published write cursor, where
// the next stage overwrites them.
func (s *store) discardStaged() {
s.inFlight, s.inFlightRecs = 0, 0
s.pendingBytes, s.pendingRecs = 0, 0
}
// peekHead returns the record at the head cursor without consuming anything:
// no cursor moves, no count changes, no owed report is paid, and no loss is
// booked. An ErrCorrupt from here is a PREVIEW, not an event — the consume op
// that eventually steps past the damage books and reports it exactly once.
func (s *store) peekHead() ([]byte, bool, error) {
payload, sum, _, ok, err := s.read(s.headOff)
if err != nil || !ok {
return nil, false, err
}
if xxhash.Sum64(payload) != sum {
return nil, false, fmt.Errorf("%w: record checksum", ErrCorrupt)
}
return payload, true, nil
}
// cycle drops any now fully-committed files and starts a fresh active file. It
// fails with ErrFull if creating the new file would exceed maxSegments.
// cycle starts a new active segment with room for at least need record bytes. A
// record too large for the standard geometry gets a segment sized to itself, so
// "records never span files" holds without capping record size at SegmentSize.
func (s *store) cycle(need int64, force bool) error {
s.dropCommitted(nil) // the soon-to-be-old active file may go; a new one follows
if !force && s.maxSegments > 0 && len(s.files) >= s.maxSegments {
return ErrFull
}
df, err := s.createFile(s.nextNum, s.writeOff, max(s.segmentSize, need))
if err != nil {
return err
}
s.nextNum++
s.files = append(s.files, df)
s.diskBytes += headerSize + df.capacity
s.trackOpen(df)
// Persist the new (and removed) entries before records land in the file.
if !s.noSync {
if err := s.syncDir(); err != nil {
return err
}
}
return nil
}
// dropCommitted removes (and closes) every fully-committed file except keep.
// Called from cycle (writes) with keep == nil — it recreates the active file
// right after, so the old full one may go — and from commitTo (commits) with
// keep == the active file, which holds the write position and must survive even
// when fully drained. Both run under the Queue lock, so no store op races it. A
// just-delivered record's file may be closed and unlinked here, in the very call
// that delivered it — the consumer's value is unaffected, because the payload it
// holds is a copy in the Reader's own buffer and was never this file's memory.
//
// A file that will not unlink stays in the live set — its records are committed,
// so it is never re-delivered, but leaving it counted keeps maxSegments a truthful
// statement about what is on disk, and the next drop retries the removal.
func (s *store) dropCommitted(keep *dataFile) {
// Files ascend by base and never overlap, so anything fully committed is a
// prefix of the slice: if the first file is not reclaimable, none are. This
// runs on every commit, so the common no-op case should not walk the backlog.
if len(s.files) == 0 || s.files[0] == keep || s.files[0].base+s.files[0].size > s.commitOff {
return
}
// A reclaim invalidates both caches: the frame boundary and the block may name
// a segment that is about to stop existing.
s.lastFrameAt, s.lastFrameEnd = 0, 0
s.dropBlock()
// Reclaimable files are a strict PREFIX — the guard above already proved
// files[0] is one, and base+size is nondecreasing — so stop at the first
// survivor instead of walking the whole backlog. This runs on every commit, and
// with MaxSegments unbounded and a deep queue it was scanning every live segment
// each time.
survive := s.files[:0]
i := 0
for ; i < len(s.files); i++ {
df := s.files[i]
if df == keep || df.base+df.size > s.commitOff {
break
}
if df.pins > 0 {
// An off-lock flush holds this handle; leave the file for the next
// reclamation pass rather than closing and unlinking it underneath the
// fdatasync. Its records are committed, so it is never re-delivered —
// exactly the un-unlinkable-file survive/retry shape, minus the counter.
survive = append(survive, df)
continue
}
if df.f != nil {
_ = df.f.Close() // read-only from here on; nothing left to lose
df.f = nil
s.untrackOpen(df)
}
if err := os.Remove(df.path); err != nil && !errors.Is(err, os.ErrNotExist) {
s.unreclaimed++
survive = append(survive, df)
continue
}
// written == committed here, so this keeps Count exact.
s.nWritten -= df.written
s.nCommitted -= df.committed
s.diskBytes -= headerSize + df.capacity
}
// survive holds only the files that would not unlink, so it is never longer
// than the prefix just scanned and this copy always moves left.
s.files = append(survive, s.files[i:]...)
}
// fileForOffset returns the file holding the record that starts at the global
// offset off (base <= off < base+size).
//
// Binary search, not a scan: files ascend by base and are contiguous, so
// base+size is nondecreasing and "extends past off" is a sorted predicate. The
// default segment cap makes the difference academic, but this runs once per
// read, and with MaxSegments unbounded a deep backlog made every read walk the
// whole slice under the queue lock.
func (s *store) fileForOffset(off int64) *dataFile {
i := sort.Search(len(s.files), func(i int) bool {
df := s.files[i]
return df.base+df.size > off
})
if i < len(s.files) && off >= s.files[i].base {
return s.files[i]
}
return nil
}
// read locates and decodes the record at global offset off, opening its file on
// demand. ok is false only at the tail (off >= writeOff); a record that should be
// present but won't decode returns ErrCorrupt (distinct from empty). An I/O
// failure is returned as its own error.
func (s *store) read(off int64) ([]byte, uint64, int64, bool, error) {
if off >= s.writeOff {
return nil, 0, 0, false, nil
}
df := s.fileForOffset(off)
if df == nil {
return nil, 0, 0, false, ErrCorrupt
}
if err := s.ensureOpen(df); err != nil {
if errors.Is(err, os.ErrNotExist) {
// The segment the header says holds this record is gone: those bytes are
// not coming back, which is what ErrCorrupt means and what the recovery
// path knows how to abandon. EMFILE/EACCES stay as themselves — they are
// transient, and a retry is the right answer there, not a lossy skip.
return nil, 0, 0, false, fmt.Errorf("%w: %w", ErrCorrupt, err)
}
return nil, 0, 0, false, err
}
p, sum, next, ok, err := s.recordAt(df, off)
if err != nil {
return nil, 0, 0, false, err
}
if !ok {
return nil, 0, 0, false, ErrCorrupt
}
return p, sum, next, true, nil
}
// takeHead reads the record at the head cursor, verifies its checksum, and
// advances.
//
// Damage never stops the queue and never comes out as data. Each event drops
// what is unreadable, moves the cursor past it and returns one ErrCorrupt — so
// the caller sees every loss and the *next* call makes progress. How much is
// dropped depends on how much can still be trusted:
//
// - the record's length framed it inside the segment but its checksum fails:
// trust the framing exactly that far and drop one record;
// - the length itself is unusable (undecodable, overrunning the segment, or the
// file is gone): the frame boundaries from here on are lost with it, so the
// rest of the segment goes.
//
// A genuine I/O error is not damage — nothing is dropped and the cursor stays
// put, because the bytes may well still be there on the next attempt.
func (s *store) takeHead() ([]byte, int64, bool, error) {
// Losses with no read of their own to report them — segments dropped at open
// — are paid out one per call, so each reaches the consumer exactly once.
if s.pendingCorrupt > 0 {
s.pendingCorrupt--
return nil, 0, false, fmt.Errorf("%w: unreadable segment dropped", ErrCorrupt)
}
payload, sum, next, ok, err := s.read(s.headOff)
if err != nil {
if !errors.Is(err, ErrCorrupt) {
return nil, 0, false, err // transient: retry, don't destroy
}
head := s.headOff
if serr := s.skipCorruptSegment(head); serr != nil {
if s.headOff == head {
// The quarantine could not be recorded and nothing moved. Reporting
// ErrCorrupt here would tell the caller the queue had stepped past
// the damage, and the documented recovery loop ("count it and go
// round again") would re-read the same bytes forever. It is a
// retriable I/O failure, so hand back the failure itself.
return nil, 0, false, serr
}
return nil, 0, false, errors.Join(err, serr)
}
return nil, 0, false, err
}
if !ok {
return nil, 0, false, nil // empty
}
if xxhash.Sum64(payload) != sum {
head := s.headOff
s.lostBytes += uint64(next - head)
s.lostRecords++