11package dao
22
33import (
4- "encoding/json"
54 "io"
65 "os"
76 "path/filepath"
7+ "strings"
88 "time"
99
1010 "github.com/cashapp/hermit/errors"
11+ "github.com/cashapp/hermit/util"
1112)
1213
14+ // staleScratchAge is how old a leftover ".tmp-*" file must be before Open
15+ // considers it abandoned rather than an in-flight write from another
16+ // process.
17+ const staleScratchAge = 24 * time .Hour
18+
1319// DAO abstracts away the database access
1420type DAO struct {
1521 stateDir string
@@ -28,109 +34,135 @@ func Open(stateDir string) (*DAO, error) {
2834 if err := os .MkdirAll (metadataDir , 0700 ); err != nil && ! os .IsExist (err ) {
2935 return nil , errors .WithStack (err )
3036 }
37+ sweepStaleScratchFiles (metadataDir )
3138 return & DAO {stateDir : stateDir , metadataDir : metadataDir }, nil
3239}
3340
41+ // sweepStaleScratchFiles removes leftover ".tmp-*" files from
42+ // util.AtomicWriteFile calls that were interrupted by a killed process (eg.
43+ // SIGKILL, which the writer's deferred os.Remove cannot run for). Best
44+ // effort: errors are ignored, and a generous age threshold avoids racing a
45+ // concurrent, genuinely in-flight write from another Hermit process.
46+ func sweepStaleScratchFiles (metadataDir string ) {
47+ entries , err := os .ReadDir (metadataDir )
48+ if err != nil {
49+ return
50+ }
51+ for _ , entry := range entries {
52+ if ! strings .Contains (entry .Name (), ".tmp-" ) {
53+ continue
54+ }
55+ info , err := entry .Info ()
56+ if err != nil || time .Since (info .ModTime ()) < staleScratchAge {
57+ continue
58+ }
59+ _ = os .Remove (filepath .Join (metadataDir , entry .Name ()))
60+ }
61+ }
62+
3463// Dump content of database to w.
3564func (d * DAO ) Dump (w io.Writer ) error {
3665 return nil
3766}
3867
39- // metadataFile is the on-disk encoding of Package written by UpdatePackage.
40- //
41- // UpdateCheckedAt is stored explicitly, rather than inferred from the file's
42- // mtime (as earlier versions of Hermit did): mtime can't be trusted to mean
43- // "the moment this etag was written" -- it's disturbed by anything else that
44- // touches the file (eg. a backup/restore), and differs in precision across
45- // filesystems.
46- type metadataFile struct {
47- Etag string `json:"etag"`
48- UpdateCheckedAt time.Time `json:"update_checked_at"`
49- }
50-
5168// GetPackage returns information for a specific package.
69+ //
70+ // The etag is stored as the raw, unencoded file content at metadataPath: this
71+ // is the exact on-disk format every Hermit version has ever written, so a
72+ // mixed-version fleet sharing a state directory can always read and write it
73+ // identically. UpdateCheckedAt is stored separately, in the sidecar file at
74+ // checkedAtPath, because mtime can't be trusted to mean "the moment this etag
75+ // was written" -- it's disturbed by anything else that touches the file (eg.
76+ // a backup/restore), and differs in precision across filesystems. An older
77+ // Hermit version, or a first-ever check, has no such sidecar: fall back to
78+ // the etag file's mtime in that case, as GetPackage always did previously.
5279func (d * DAO ) GetPackage (pkgRef string ) (* Package , error ) {
53- r , err := os .Open (d .metadataPath (pkgRef ))
80+ etag , err := os .ReadFile (d .metadataPath (pkgRef ))
5481 if os .IsNotExist (err ) {
5582 return nil , nil
5683 }
5784 if err != nil {
5885 return nil , errors .WithStack (err )
5986 }
60- defer r .Close ()
61- info , err := r .Stat ()
87+ checkedAt , err := d .readCheckedAt (pkgRef )
6288 if err != nil {
6389 return nil , errors .WithStack (err )
6490 }
65- data , err := io .ReadAll (r )
66- if err != nil {
67- return nil , errors .WithStack (err )
68- }
69- var mf metadataFile
70- if err := json .Unmarshal (data , & mf ); err != nil {
71- // Metadata file written by a Hermit version prior to the
72- // introduction of this format: it contains only the raw etag, with
73- // no recorded check time. Fall back to the file's mtime, as
74- // GetPackage always did previously.
75- return & Package {
76- Etag : string (data ),
77- UpdateCheckedAt : info .ModTime (),
78- }, nil
91+ if checkedAt .IsZero () {
92+ info , err := os .Stat (d .metadataPath (pkgRef ))
93+ if err != nil {
94+ return nil , errors .WithStack (err )
95+ }
96+ checkedAt = info .ModTime ()
7997 }
8098 return & Package {
81- Etag : mf . Etag ,
82- UpdateCheckedAt : mf . UpdateCheckedAt ,
99+ Etag : string ( etag ) ,
100+ UpdateCheckedAt : checkedAt ,
83101 }, nil
84102}
85103
86- // UpdatePackage updates the update check time, etag, and the used at time for a package.
104+ func (d * DAO ) readCheckedAt (pkgRef string ) (time.Time , error ) {
105+ data , err := os .ReadFile (d .checkedAtPath (pkgRef ))
106+ if os .IsNotExist (err ) {
107+ return time.Time {}, nil
108+ }
109+ if err != nil {
110+ return time.Time {}, errors .WithStack (err )
111+ }
112+ checkedAt , err := time .Parse (time .RFC3339Nano , string (data ))
113+ if err != nil {
114+ // A torn read of the sidecar (or one written by an incompatible
115+ // future version) is not fatal: fall back to mtime rather than
116+ // failing the whole lookup.
117+ return time.Time {}, nil //nolint:nilerr
118+ }
119+ return checkedAt , nil
120+ }
121+
122+ // UpdatePackage updates the update check time and etag for a package.
87123//
88- // The write is atomic : content is written to a temp file in the same
89- // directory, then renamed into place. os.WriteFile is not atomic -- it
90- // truncates the existing file before writing the new content -- so a
124+ // Both files are written atomically : content is written to a temp file in
125+ // the same directory, then renamed into place. os.WriteFile is not atomic --
126+ // it truncates the existing file before writing the new content -- so a
91127// concurrent GetPackage could otherwise observe a torn read (empty or
92128// partial etag). A torn read here is not merely cosmetic: UpgradeChannel
93129// treats any etag change, including a corrupted one, as a reason to
94130// evictPackage (rm -rf) a package tree that another process may be actively
95131// executing.
132+ //
133+ // The etag is written first: if the process dies between the two writes, a
134+ // concurrent GetPackage falls back to the etag file's mtime for
135+ // UpdateCheckedAt (see above), which is the same degraded-but-safe behaviour
136+ // as running against an older Hermit version that never writes the sidecar
137+ // at all.
96138func (d * DAO ) UpdatePackage (pkgRef string , pkg * Package ) error {
97- path := d .metadataPath (pkgRef )
98139 checkedAt := pkg .UpdateCheckedAt
99140 if checkedAt .IsZero () {
100141 checkedAt = time .Now ()
101142 }
102- data , err := json .Marshal (metadataFile {Etag : pkg .Etag , UpdateCheckedAt : checkedAt })
103- if err != nil {
143+ if err := util .AtomicWriteFile (d .metadataPath (pkgRef ), []byte (pkg .Etag ), 0600 ); err != nil {
104144 return errors .WithStack (err )
105145 }
106-
107- tmp , err := os .CreateTemp (d .metadataDir , filepath .Base (path )+ ".tmp-*" )
108- if err != nil {
109- return errors .WithStack (err )
110- }
111- tmpPath := tmp .Name ()
112- // Harmless once the rename below succeeds: nothing left to remove.
113- defer os .Remove (tmpPath )
114-
115- _ , writeErr := tmp .Write (data )
116- closeErr := tmp .Close ()
117- if writeErr != nil {
118- return errors .WithStack (writeErr )
119- }
120- if closeErr != nil {
121- return errors .WithStack (closeErr )
122- }
123- return errors .WithStack (os .Rename (tmpPath , path ))
146+ return errors .WithStack (util .AtomicWriteFile (d .checkedAtPath (pkgRef ), []byte (checkedAt .Format (time .RFC3339Nano )), 0600 ))
124147}
125148
126149// DeletePackage removes a package from the DB
127150func (d * DAO ) DeletePackage (pkgRef string ) error {
128151 if err := os .Remove (d .metadataPath (pkgRef )); err != nil {
129152 return errors .WithStack (err )
130153 }
154+ // The checked-at sidecar may not exist (eg. written by an older Hermit
155+ // version); that's not an error.
156+ if err := os .Remove (d .checkedAtPath (pkgRef )); err != nil && ! os .IsNotExist (err ) {
157+ return errors .WithStack (err )
158+ }
131159 return nil
132160}
133161
134162func (d * DAO ) metadataPath (pkgRef string ) string {
135163 return filepath .Join (d .metadataDir , pkgRef + ".etag" )
136164}
165+
166+ func (d * DAO ) checkedAtPath (pkgRef string ) string {
167+ return filepath .Join (d .metadataDir , pkgRef + ".checked" )
168+ }
0 commit comments