timberfs 0.10.1

Experimental append-only, transparently compressed, write-time-indexed filesystem for log files
Documentation
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
.TH TIMBERFS 1 "2026-07-10" "timberfs 0.1.0" "User Commands"
.SH NAME
timberfs \- append-only, transparently compressed, write-time-indexed filesystem for log files
.SH SYNOPSIS
.B timberfs mount
.RI [ options ]
.I BACKING
.I MOUNTPOINT
.br
.B timberfs create
.RB [ \-\-index ]
.RB [ \-\-set
.IR KEY=VALUE ]...
.I DEST
.br
.B timberfs append
.RI [ options ]
.B \-\-into
.I DEST
.br
.B timberfs import
.RI [ options ]
.B \-\-into
.I DEST
.IR SOURCE .\|.\|.
.br
.B timberfs export
.I SOURCE
.B \-\-into
.I DEST
.RB [ \-\-from
.IR TIME ]
.RB [ \-\-to
.IR TIME ]
.I SOURCE
.I DEST
.br
.B timberfs query
.RB [ \-\-from
.IR TIME ]
.RB [ \-\-to
.IR TIME ]
.IR FILE .\|.\|.
.br
.B timberfs index
.I FILE
.br
.B timberfs reindex
.I FILE
.br
.B timberfs rotate
.B \-\-cutoff
.I TIME
.I SOURCE
.RI [ DEST ]
.RB [ \-\-delete ]
.RB [ \-\-dry\-run ]
.SH DESCRIPTION
.B timberfs
is a FUSE filesystem purpose-built for log files. Files in the mount look
and behave like ordinary log files \(em loggers append,
.BR tail (1)
and
.BR grep (1)
work unmodified \(em but the data is stored in a backing directory as
chunks compressed with zstd, together with a per-chunk
.B write-time index
that records when each chunk was written. Asking \(lqwhat was written
between 13:42 and 13:43?\(rq is a binary search plus a few frame
decompressions, independent of file size.
.PP
Files are append-only: writes anywhere but the end of the file fail with
.BR EPERM .
Truncating a file to zero is allowed and starts it over
(copytruncate-style rotation); rename and unlink work normally.
.B ls \-l
shows the logical (uncompressed) size while
.B du
shows the real, compressed disk usage.
.SH SUBCOMMANDS
.SS mount
.B timberfs mount
.I BACKING MOUNTPOINT
serves the logical view of backing directory
.I BACKING
on
.IR MOUNTPOINT ,
in the foreground. Unmount with
.B fusermount3 \-u
.IR MOUNTPOINT .
.TP
.BI \-\-chunk\-size " BYTES"
Uncompressed buffer size that triggers a chunk flush (default 262144).
.TP
.BI \-\-level " N"
zstd compression level (default 3).
.TP
.BI \-\-flush\-age " SECONDS"
Maximum time appended data may sit unflushed (default 5). Bounds both the
time granularity of the index and the data lost in a crash without
.BR fsync (2).
.TP
.B \-\-allow\-other
Let other users access the mount. Unprivileged users need
.B user_allow_other
in
.IR /etc/fuse.conf ;
root (e.g. the systemd unit) does not.
.SS create
.B timberfs create
.I DEST
makes an empty log with its properties declared up front in a
.B .bark
manifest \(em one flat, optional, human-editable JSON object beside the
pair.
.B \-\-index
declares the token index, database-style: once declared, EVERY import
maintains the
.B .grain
automatically (extended incrementally for new chunks, rebuilt if it went
missing \(em say, dropped by rotation or retention), so there is no
per-import flag to forget.
.B \-\-set
.I KEY=VALUE
records free-form provenance
.RB ( host ,
.BR path ,
.BR format ,
\|.\|.\|.). Every manifest is minted with a durable identity on first write:
.B id
(a random UUID, constant across renames, moves and hosts \(em the
identity of the store, where paths are merely its current address) and
.B created
(RFC3339). Artifacts made by
.B export
and by rotation into a new segment are NEW stores: they get a fresh
identity, a
.B derived_from
pointer to the source's id (lineage chains compose across re-carves and
shipping), a
.B derived_op
of
.BR export / rotate
(copy vs move \(em whether the source still holds the data), and inherit
the data-provenance fields; settings
.RB ( index )
do not inherit. Rotation mints its source's identity when missing (it
holds the writer locks); export never writes its source. Content facts
\(em actual spans, sizes \(em are deliberately NOT recorded in bark: the
artifact's own rings state them authoritatively. The REQUESTED window is
different \(em an operation fact, like
.B derived_op
\(em and a windowed export records it as
.BR window_from / window_to
(RFC3339 UTC; only the bounds given; never inherited). Content can never
state coverage \(em an artifact whose last line is 17:00 doesn't say
whether 17:00\(en24:00 was covered-but-silent or not covered \(em and for
an EMPTY artifact the declared window is its entire meaning. Unlike the
derived
.BR .grain ,
bark survives head-drops, travels on rename, and ships inside
.B .timber
bundles.
.SS set
.B timberfs set
.I FILE KEY=VALUE\|.\|.\|.
declares or changes a store's properties in its
.B .bark
manifest \(em validated and atomic, which hand-editing the JSON is
neither. Known settings are parse-checked
.RB ( retain =90d,
.BR retain_size =50G,
.BR index =true|false);
anything else is free-form provenance
.RB ( host =...).
.B \-\-unset
.I KEY
removes a key. Identity and lineage
.RB ( id ,
.BR created ,
.BR derived_* )
are facts, not settings, and are refused. Works on LIVE stores: writers
re-read the manifest on their once-a-second retention tick, so
.B timberfs set backing/app.log retain=30d
takes effect within a second with no restart \(em restarting a writer
means restarting whatever pipes into it. A manifest that stops parsing
mid-flight (a bad hand-edit) keeps the writer alive on its last good
policy, with one warning: never silently unbounded, never a dead
producer.
.SS append
.B timberfs append \-\-into
.I DEST
reads standard input and appends it to a log in the backing store directly,
with no FUSE mount involved \(em the
.BR svlogd (8)/s6-log
pattern (destinations are always named
.BR \-\-into ,
here as everywhere):
.PP
.nf
.RS
myapp 2>&1 | timberfs append \-\-into logs\-backing/app.log
.RE
.fi
.PP
With
.B \-\-records
standard input is a
.BR timberfs\-records (5)
stream instead of raw text: entries arrive pre-framed, and an entry
carrying its original write window
.RB ( wf / wl )
keeps it \(em write history survives the pipe. Without a carried
window, append stamps now, as always \(em the stream's word is law,
silence falls back to the command's own clock. Delivery is streaming:
data lands as it arrives, and a truncated stream keeps what came and
fails the exit code (at-least-once \(em re-running may duplicate; use
.B import \-\-records
for all-or-nothing).
.PP
Each log has exactly one writer (an exclusive per-file lock), but
appenders for different files happily share a backing directory; a
directory served by a
.B timberfs mount
is unavailable to appenders and vice versa. Appending to an existing log
continues it. Data is flushed into chunks by the same size and age rules
as the mount; end of input,
.B SIGTERM
or
.B SIGINT
flush and sync everything before exit. Accepts the same
.BR \-\-chunk\-size ,
.B \-\-level
and
.B \-\-flush\-age
options as
.BR mount ,
plus:
.TP
.BI \-\-retain " DURATION"
Continuously drop data older than
.I DURATION
(e.g.
.BR 30d ,
.BR 12h ,
.BR 90m ).
.TP
.BI \-\-retain\-size " SIZE"
Keep the on-disk (compressed) size of the log at or under
.I SIZE
(e.g.
.BR 200G ,
.BR 512M ;
powers of 1024), dropping the oldest data first. Combines with
.BR \-\-retain :
whichever limit bites first wins.
.PP
Retention is checked every second, at startup and once more at shutdown.
Dropping the head currently compacts the log by rewriting the remaining
data, so enforcement is batched: age-expired data is dropped once it makes
up about a tenth of the file, and a size overrun is trimmed down to 95% of
the budget. Compaction briefly needs free disk space proportional to the
retained data.
.SS import
.B timberfs import \-\-into
.I DEST
.IR SOURCE .\|.\|.
converts existing plain log files into a timberfs log. The destination is
a named flag on purpose: a shell glob among the sources can never
swallow it. With
.B \-\-records
the single source (a file, or standard input when none is given) is a
.BR timberfs\-records (5)
stream: entries arrive pre-framed, an entry carrying its original
write window
.RB ( wf / wl )
keeps it \(em replicating a store preserves its write history \(em
and one without falls back to import's own clock, the entry's parsed
timestamp (the backfill doctrine). Delivery is ATOMIC: nothing is
visible until the stream's
.B stream\-end
arrives, and a truncated stream leaves the store byte-for-byte
unchanged. The upstream selection echo and every pipeline
.B stage=
land in the destination's manifest \(em an artifact remembers the
pipe that filled it:
.PP
.nf
.RS
timber\-filter \-\-records \-\-has ERROR \-\-from 13:00 app.log |
    timberfs import \-\-records \-\-into case.timber.d/case.log
timberfs query \-\-records app.log | ssh host timberfs import \-\-records \-\-into mirror/app.log
.RE
.fi Several sources \(em a rotated set \(em may be given
in any order: each file's first parsed timestamp determines the stitch
order (rotation numbering and glob order lie), and the plan is printed
before anything is written. Two sources starting at the same timestamp
are refused as likely duplicates.
.PP
Each plain source is placed by its
.B first timestamp
relative to what the destination already holds. Starting
.I after
the destination's last write, it simply appends (the daily bulk-load).
Starting
.I inside
the destination's window \(em day files cut with slack, an import re-run
\(em the overlap is deduplicated
.BR "line by line" :
lines the destination already has are skipped, genuinely new lines in
the covered window are imported (with a warning, since that usually
means the captures disagreed), and re-importing an already-covered file
is a clean no-op. Counts are reported. Starting
.I before
everything in the destination is refused \(em import in chronological
order or to a new target. A source starting exactly
.B where the destination starts
is treated as the same file, regrown: the already-imported prefix is
byte-verified and only the growth is appended (truncated or rewritten
files are refused before anything is written; see
.BR \-\-quick ).
.PP
A source that is itself a timberfs log \(em a rotation segment shipped
from another machine, say \(em is detected automatically (a
.BR .trunk / .rings
path, or a logical name whose pair exists) and its chunks are merged
.B verbatim
into the destination: no decompression, no timestamp parsing, index
included, cost proportional to the compressed size. A segment whose exact
chunks are already in the destination is skipped, so re-running a
shipping script is a no-op; merging refuses to break the destination's
time ordering (import segments oldest-first). Plain and timberfs sources
can be stitched together. Chunk time
windows come from timestamps
.B parsed from the log lines
(historical data's write time is meaningless), so
.B query
works by the times the log actually talks about. Auto-detected timestamps
must sit at the start of the line: RFC 3339 / ISO 8601 and common
variants (space instead of
.BR T ,
dotted dates, and
.BR . / , / :
before the milliseconds \(em logback's
.B yyyy.MM.dd HH:mm:ss:SSS
included) and leading epoch seconds/milliseconds; Apache/CLF
.RB ( [10/Jul/2026:09:23:45\ +0200] )
is the one mid-line exception. Anything else via
.B \-\-timestamp\-regex
(one capture group, searched \(em anchor with
.B ^
if wanted) plus
.B \-\-timestamp\-format
(chrono strftime). Zoneless timestamps are taken as local time unless
.BR \-\-utc .
Lines without a timestamp (stack traces, continuations) inherit the
previous line's, so multiline entries land in the right window; a file
that STARTS mid-entry backfills its head lines with the first timestamp
found, and in a stitch they inherit the previous file's last stamp \(em
exactly right for entries split by rotation. Mildly out-of-order lines
widen chunk windows instead of getting lost. Accepts
.B \-\-chunk\-size
and
.BR \-\-level ,
and
.B \-\-index
declares and builds the
.B .grain
token index \(em needed once per log (the declaration persists in the
.B .bark
manifest; see
.BR create ),
after which every import maintains the index automatically.
.PP
Re-importing is idempotent: a non-empty destination is treated as a
checkpoint. The already-imported bytes are verified against the same
source ranges (every chunk, or first/middle/last with
.BR \-\-quick ),
then only what the source has grown by is appended; an identical source
is a no-op, and a source that shrank or changed is refused before
anything is written (rotated files should be imported to a new target).
.SS export
.B timberfs export
.I SOURCE
.B \-\-into
.I DEST
copies the chunks overlapping the given window (default: everything) into
a NEW timberfs log \(em verbatim, no recompression, cost proportional to
the compressed size of the window. The source needs no lock and may be
live. A
.I DEST
ending in
.B .timber
writes the single-file transfer bundle instead: a plain uncompressed tar
(the payload is already zstd) holding the
.B .rings
member first and the
.B .trunk
member second.
Bundles are first-class READ-ONLY logs:
.BR query ,
.B index
and
.B export
operate on a
.B .timber
file directly (tar stores its members contiguously and uncompressed, so
the trunk member is simply a trunk at an offset), and
.B import
merges them — unpacking is only ever needed to append. Stock tools always
suffice to recover one:
.B tar xf x.timber && zstd \-dc x.trunk
(a pair tarred by hand follows the same convention). Export always
creates; merging into an existing log is
.BR import 's
job.
.PP
A window that selects nothing still exports: an empty artifact whose bark
records the requested window is evidence of absence (\(lqSaturday was
covered, nothing was there \(em ingest Sunday\(rq), where a MISSING file
is absence of evidence (\(lqa day is missing \(em don't ingest past the
gap\(rq). Opposite signals to a consumer, so an empty result is a result,
not an error;
.B \-\-fail\-on\-empty
makes it one for pipelines that want a quiet day to be loud.
.B import
treats empty sources the same way: skipped with a note, never fatal, and
an all-empty import into a new destination still creates it.
.SS query
.B timberfs query
.I FILE
prints the log entries inside the given time window to standard output,
reading the backing files directly \(em it works with or without an active
mount, but only sees flushed chunks (the still-buffered tail, at most
flush-age old, is visible only through the mount).
.I FILE
is a backing file: the logical name, or its
.B .trunk
or
.B .rings
path.
.TP
.BI \-\-from " TIME"
Start of the window (default: beginning of the file).
.TP
.BI \-\-to " TIME"
End of the window (default: end of the file).
.TP
.BI \-\-has " TOKEN"
Only chunks that (probably) contain
.IR TOKEN ,
tested against the
.B .grain
Bloom index (build it with
.BR "timberfs reindex" ;
without one, \-\-has warns and scans the window). Matching is by WHOLE
token \(em ASCII-alphanumeric runs of 3\(en64 characters, exact case;
substrings do not match. An argument containing separators
.RB ( req\-8f3a )
must match all its tokens in the same chunk; repeat
.B \-\-has
for chunk-level AND;
.BI \-\-any " TEXT"
(repeatable) keeps chunks where at least ONE alternative matches \(em
the union of exact branches, still exact \(em and composes with
.BR \-\-has .
About 1% of non-matching chunks pass anyway (Bloom
false positives) \(em the exact, entry-level filtering belongs in
.BR timber\-filter (1)
downstream. The killer use: finding a unique identifier with NO time
bound.
.TP
.BR \-f ", " \-\-follow
After the selected output, keep emitting entries as chunks are committed,
until interrupted \(em like
.BR tail (1)
.BR \-f .
A flushed chunk is the unit of visibility, so new data appears within the
writer's
.B \-\-flush\-age
(default 5s), not per line. Read-only and lock-free, so it runs beside a live
appender; plain text streams raw bytes, while
.BR \-0 ", " \-\-records ", and " \-\-show\-write\-time
run the entry pipeline. Filter a live follow with a pipe
.RB ( "| grep" );
.B \-\-has
selects whole chunks offline and does not compose with it.
.TP
.BI \-\-tail " N"
Show (about) the last
.I N
log entries \(em a stamped line and its continuation lines are one entry \(em
then, with
.BR \-\-follow ,
keep going. Chunk-granular: rounded out to a whole chunk, so a few extra may
precede the Nth-from-last. (A store with no parseable timestamps counts lines.)
.TP
.BI \-\-max " N"
Stop after at most
.I N
entries (a hard cap, like
.BR head (1)).
Composes with
.B \-\-follow
to bound it; conflicts with
.BR \-\-tail .
.PP
Several
.I FILE
arguments give the fleet view over per-stream logs: chunks are selected
per file (each file's own rings and grain) and interleaved across files
by their time windows, with within-file order preserved; every output
line carries a grep-style
.RB \(lq path: \(rq
prefix
.RB ( \-\-no\-filename
to suppress). Attribution lives in the filename \(em store one log per
host/app and merge at read time.
.PP
Selection is
.B chunk-granular
by design: every chunk whose write-time window overlaps the requested
range is emitted in full, so the result carries at most flush-age worth of
slop at each edge. The intended workflow is to let
.B query
do the coarse seek into a huge file, then trim exactly with
.BR timber\-filter (1)
on the timestamps the log lines carry anyway.
.SS searching
Matching lives in its own tool:
.BR timber\-filter (1),
the entry-aware pipeline filter. A store argument is searched through
the selection layer (it runs
.B timberfs query \-\-records
itself), so the fused and piped spellings are the same search:
.PP
.nf
.RS
timber\-filter \-\-has ERROR \-\-from 13:00 backing/app.log
timberfs query \-\-records \-\-from 13:00 backing/app.log | timber\-filter \-\-has ERROR
.RE
.fi
.PP
The investigation-as-artifact workflow is a pipe into the records sink
\(em the destination's manifest records the selection and every stage
that shaped the data:
.PP
.nf
.RS
timber\-filter \-\-records \-\-has 'tenantId=FOO' \-\-from 13:00 backing/app.log |
    timberfs import \-\-records \-\-into case/case.log
timberfs export case/case.log \-\-into case.timber   # attach to the ticket
.RE
.fi
.SS info
.B timberfs info
.I FILE
shows a store's vital signs on one screen: identity and creation time,
lineage (what it derives from, by which operation, over which requested
window, and \(em for records-sink
artifacts \(em the command line that produced it, labelled
.BR question ),
declared provenance, logical size vs on-disk size and compression
ratio, the time span covered and its duration, index sizes with grain
.B coverage
(a grain lagging its log \(em appender writes, partial extends \(em
covers fewer chunks than the rings; the gap is scanned), and the writer
state (none / active appender or import / mounted, probed from the
actual flocks, never from stale lock-file contents). Works identically
on backing pairs and read-only
.B .timber
bundles.
.B \-\-json
prints the same facts as one JSON object for scripting. Read-only and
safe against live writers.
.SS index
.B timberfs index
.I FILE
prints the chunk index of a backing file: per chunk the uncompressed
offset and length, compressed length, compression ratio, and first/last
write time, plus totals.
.SS list
.B timberfs list
.RI [ DIR " ...]"
is the directory-level complement to
.BR info :
one row per store, sorted by forest then handle, across every configured
.B forest
(see
.B FORESTS
below), or \(em when one or more
.I DIR
arguments are given \(em exactly the stores in those directories
(ad-hoc; they need not be configured forests). Columns:
.B HANDLE
(the forest handle),
.B FOREST
(the config filename minus
.IR .conf ,
or the directory itself for an ad-hoc/
.B TIMBERFS_FORESTS
forest),
.B SIZE
(compressed on-disk size),
.B SPAN
(earliest \(em latest write time covered, or
.IR empty ),
.B WRITER
.RI ( live
if a writer currently holds the file's own lock, else
.IR \- ),
.B INDEX
.RI ( grain
if a
.B .grain
token index is present or declared, else
.IR \- ),
and
.B RETAIN
(declared
.BR retain / retain_size ,
else
.IR \- ).
The same handle found in two forests shows up as two rows \(em
.B list
is how an ambiguity that handle resolution would refuse to guess at
becomes visible, not something it hides.
.TP
.B \-\-names
Bare handles only, one per line, no header or columns \(em what shell
completion consumes.
.TP
.B \-\-json
A JSON array of objects
.RI ( handle ", " forest ", " dir ", " path ", " size_bytes ", "
.IR from_ms ", " to_ms ", " writer_live ", " indexed ", " retain ", "
.IR retain_size )
instead of the human table.
.PP
No forests configured and no
.I DIR
given prints a note to stderr and exits 0; a forest or
.I DIR
that doesn't exist is skipped (also noted). Read-only and lock-free \(em
like
.BR info ,
it only ever probes a writer lock, never acquires one, so it works for a
non-root reader on a root-owned forest.
.SS reindex
.B timberfs reindex
.I FILE
builds (or rebuilds) the
.B .grain
token index for a log: one Bloom filter per chunk over every distinct
token in it, ~10 bits per token, ~1% false positives \(em roughly 1\(en2%
of the raw data size. Derived data under the sidecar contract: safe to
delete, cheap to rebuild, and rotation/retention drop it (rebuild
afterwards). A grain that lags a growing log is still correct \(em
uncovered chunks are simply scanned.
.SS rotate
.B timberfs rotate \-\-cutoff
.I TIME SOURCE
.RI [ DEST ]
moves every chunk written entirely before
.I TIME
out of
.I SOURCE
and appends it to
.I DEST
(created if missing, must be in the same backing directory). Compressed
frames are relocated verbatim \(em nothing is recompressed \(em so the
cost is proportional to the compressed size. A chunk straddling the cutoff
stays in the source. Appending to an existing
.I DEST
is refused if it would break the destination's time ordering.
.PP
Rotation auto-detects a live mount: if a daemon serves the backing
directory (advertised via the lock file), the request is routed through it
and performed atomically; otherwise the backing files are rewritten
directly under the same lock.
.PP
Rotating nothing into a new
.I DEST
still creates it, empty \(em the same present-but-empty vs missing
distinction as an empty
.BR export ,
for pipelines that rotate-then-ship.
.TP
.B \-\-delete
Drop the rotated chunks instead of moving them (retention). Mutually
exclusive with
.IR DEST .
.TP
.B \-\-dry\-run
Show what would move without changing anything.
.TP
.B \-\-fail\-on\-empty
Error when nothing rotates instead of attesting the empty result.
.SH RETENTION
Retention is a property of the LOG, declared in its manifest
.RB ( retain
= keep at least this long,
.B retain_size
= compressed-size budget, oldest dropped first, whichever bites first)
and enforced by
.B every
writer while it holds the writer locks: the appender and the mount
daemon on their once-a-second tick, and
.B import
after each run \(em so timer-mode (cron import) stores and mounted
stores get retention too, with no appender running. Declare it at
.BR create ,
with
.BR set ,
or via the appender's
.BR \-\-retain / \-\-retain\-size
flags (which persist the declaration \(em all roads converge, and a
restarted writer can no longer silently lose its policy). Absent keys
mean no limits: an artifact carved by
.B grep \-\-into
has no business expiring, which is also why retention, like all
settings, is never inherited by derived stores.
.B timberfs info
shows the declared policy and calls out a store that is over budget
with no writer running to enforce it.
.SH FORESTS
A
.I forest
is a directory
.B timberfs
searches for stores by a short
.IR handle ,
so
.B timberfs query nginx
finds
.I /var/log/timberfs/nginx/nginx.log
without spelling out the path. Handle lookup applies to the source
argument of
.BR query ", " info ", " index ", " reindex ", " set ", " rotate " and " export ;
a full path, a relative path or a
.B .timber
bundle is always taken literally, so every existing invocation is
unchanged \(em a forest is consulted only for a bare token (no
.BR / )
that names no store on disk.
.PP
Forests are configured by
.IR /etc/timberfs/forests.d/*.conf ,
one forest per file, the same
.B KEY=VALUE
idiom as an instance
.IR .conf ,
read in sorted filename order. The only key is
.BR DIR ,
an absolute directory; blank lines,
.B #
comments and unknown keys are ignored, and a configured directory that
does not exist is skipped. The package ships
.I default.conf
with
.BR DIR=/var/log/timberfs ;
edit it, drop in another
.IR .conf ,
or delete it to disable handle lookup (it is a conffile, so edits
survive upgrades).
.PP
A store's handle is its
.B .rings
file name minus
.B .rings
and a single trailing
.BR .log ,
so a flat
.I nginx.rings
and a nested
.I nginx/nginx.log.rings
both resolve as
.BR nginx ", while " metrics.jsonl.rings " keeps its " .jsonl .
Each forest's root and its immediate subdirectories are scanned. A
handle that matches no store, or more than one, is an error that names
what was searched and asks for a full path.
.PP
The environment variable
.B TIMBERFS_FORESTS
(colon-separated absolute directories) replaces the configuration
entirely \(em for tests and one-off use.
.SH SHELL COMPLETION
The package ships completion scripts for
.B bash
and
.BR zsh ,
installed to the standard vendor locations
.RI ( /usr/share/bash\-completion/completions/timberfs " and "
.IR /usr/share/zsh/vendor\-completions/_timberfs )
so both shells load them automatically, with no per-user setup.
.B timberfs
.RB [ TAB ]
lists the subcommands; the store argument of
.BR query ", " info ", " index ", " reindex ", " set " and the source of "
.BR rotate " and " export
additionally completes bare
.B FORESTS
handles, gathered live from
.B timberfs list \-\-names
and offered alongside normal file-path completion. With no forests
configured (or if that call errors), completion silently falls back to
file paths \(em never an error at the prompt.
.SH GLOBAL OPTIONS
.TP
.B \-\-quiet
Suppress informational notes on standard error \(em scan reports,
progress, stitch plans, summaries. Errors and warnings still print.
Accepted by every subcommand. (Long-only on purpose:
.B \-q
keeps its grep(1) meaning available.)
.SH THE TWO CLOCKS
Every entry effectively has two timestamps: the one its line CARRIES
(what happened when \(em the one you can see) and the WRITE time it
arrived in the store (the one the index searches). For imported data
they are identical; for live data they differ by buffering. By default,
.B query
and
.B grep
select chunks by the write-time index (widened by about a minute to
catch buffered stragglers), then verify every entry against
.BR \-\-from / \-\-to
by the timestamp its own line carries \(em so the output answers the
question you asked, in the timestamps you can see. Entries whose
timestamps cannot be read are always included, never hidden; a store
with no parseable timestamps at all falls back to the raw, unwidened
write-time window with a note.
.B \-\-show\-write\-time
annotates each entry with the write window it arrived in \(em and, when
its own stamp falls outside that window, the offset \(em making any
divergence visible and explainable.
.B \-\-by\-write\-time
is the raw escape hatch: chunk output selected by write time only, no
parsing (the pre-0.7.4 behavior).
.B \-0
.RB ( \-\-null )
emits NUL-terminated entry RECORDS \(em a multiline entry (stack trace)
stays one record \(em for
.BR "xargs \-0" ,
.BR "sort \-z" ,
.B "uniq \-z"
pipelines.
.B \-\-records
emits the typed record stream for timber-aware consumers: the same
NUL-framed entries interleaved with metadata records carrying the
format version, an echo of the selection, per-source index statistics,
each entry's length, own timestamp and write window, and end-of-stream
totals whose presence proves the stream arrived complete \(em see
.BR timberfs\-records (5)
for the grammar. Plain
.B \-0
stays the choice for ordinary Unix tools. Persistent whole-hour offsets between the two clocks are
detected and warned about once (on one host the clocks cancel, so such
an offset is a timezone or format misconfiguration, not clock skew).
Declare exotic line formats once in the manifest
.RB ( timestamp_regex ,
.BR timestamp_format ,
.B timestamp_utc
via
.B timberfs set
\(em or import's flags, which persist the declaration); they describe
the CONTENT, so unlike settings they inherit into derived artifacts.
Do not backfill historical data through
.B append
(its index gets today's write times): import the file instead.
.SH TIME FORMATS
.I TIME
arguments accept RFC 3339
.RB ( 2026\-07\-10T13:42:00+02:00 ),
.BR "YYYY\-MM\-DD HH:MM" [ :SS ]
(local time; a
.B T
separator also works, and dots as date separators \(em paste straight
from logback-style logs), a bare
.B YYYY\-MM\-DD
(midnight local time, so
.B \-\-from 2026\-07\-10 \-\-to 2026\-07\-11
selects exactly that day),
.BR HH:MM [ :SS ]
(today, local time), and unix epoch seconds or milliseconds.
.SH ON-DISK FORMAT
Each logical file
.I name
is backed by two files in the backing directory:
.TP
.IB name .trunk
The data: a plain concatenation of zstd frames, one per chunk, with no
wrapper bytes. The full uncompressed content is therefore always
recoverable with stock tools:
.B zstd \-dc
.IB name .trunk
.TP
.IB name .rings
The index: an 8-byte magic
.RB \(lq RING0001 \(rq
followed by fixed-size 48-byte records (little-endian u64 fields:
uncompressed start/length, compressed start/length, first/last write time
in unix milliseconds), appended in write order.
.PP
Chunks are written data-first, index-second; on open, index records
pointing past the end of the data are dropped and orphaned data bytes are
overwritten.
.BR fsync (2)
through the mount flushes the buffer as a chunk and syncs both backing
files.
.PP
The daemon holds an exclusive
.BR flock (2)
on
.IB backingdir /.timberfs.lock
recording its mountpoint; offline rotation takes the same lock.
.SH EXTENDED ATTRIBUTES
Files in the mount expose read-only metadata via
.BR getfattr (1):
.BR user.timberfs.chunks ,
.BR user.timberfs.compressed_size ,
.BR user.timberfs.first_write ,
.BR user.timberfs.last_write .
The attribute
.B user.timberfs.rotate
is a write-only control interface used internally by
.BR "timberfs rotate" .
.SH SYSTEMD
The Debian package ships a template unit
.BR timberfs@.service :
create
.IB /etc/timberfs/ instance .conf
defining
.BR BACKING ,
.B MOUNTPOINT
and optionally
.BR EXTRA_OPTS ,
then
.B systemctl enable \-\-now
.BI timberfs@ instance .
Stopping the unit unmounts first, so the daemon flushes all buffers and
exits cleanly. An example configuration is installed under
.IR /usr/share/doc/timberfs/examples/ .
.PP
A socket\-activated log\-intake pair,
.B timberfs\-log@.socket
and
.BR timberfs\-log@.service ,
streams a producer's
.BR timberfs\-records (5)
stream into a store over a FIFO.
The full directory layout, both unit families, the ownership model and
self\-restart on upgrade are documented in
.IR /usr/share/doc/timberfs/deployment.md .
.SH EXAMPLES
Mount and use:
.PP
.nf
.RS
timberfs mount ./logs\-backing ./logs &
myapp >> logs/app.log
tail \-f logs/app.log
.RE
.fi
.PP
Or skip FUSE entirely and pipe:
.PP
.nf
.RS
myapp 2>&1 | timberfs append \-\-into logs\-backing/app.log
.RE
.fi
.PP
Extract a time window from a huge log, then trim with grep:
.PP
.nf
.RS
timberfs query logs\-backing/app.log \-\-from 13:42 \-\-to 13:43 | grep ERROR
.RE
.fi
.PP
Daily rotation and 30-day retention, no recompression:
.PP
.nf
.RS
timberfs rotate logs\-backing/app.log app\-$(date \-d yesterday +%F).log \\
    \-\-cutoff "$(date +%F) 00:00"
timberfs rotate logs\-backing/app.log \-\-delete \\
    \-\-cutoff "$(date \-d '30 days ago' +%F) 00:00"
.RE
.fi
.PP
Disaster recovery with stock tools only:
.PP
.nf
.RS
zstd \-dc logs\-backing/app.log.trunk > app.log.recovered
.RE
.fi
.SH EXIT STATUS
0 on success, non-zero on any error (2 for command-line usage errors).
.SH SEE ALSO
.BR timberfs-records (5)
.br
.BR fusermount3 (1),
.BR zstd (1),
.BR getfattr (1),
.BR systemd.unit (5)
.PP
Project page and full design notes:
.UR https://github.com/torstei/timberfs
.UE