Skip to content

Commit 22f33ea

Browse files
authored
Merge pull request #11 from getlantern/afisk/initfrom-file-modtime
Record a cached file's age, not its load time, when initializing from it
2 parents f204338 + f04f036 commit 22f33ea

3 files changed

Lines changed: 252 additions & 4 deletions

File tree

‎initfrom_test.go‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package keepcurrent
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// lastModifiedServer serves body with Last-Modified mod, answering 304 to an
18+
// If-Modified-Since at or after it, as a CDN or object store does.
19+
func lastModifiedServer(t *testing.T, mod time.Time, body string) *httptest.Server {
20+
t.Helper()
21+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
22+
if ims, err := http.ParseTime(r.Header.Get("If-Modified-Since")); err == nil && !mod.After(ims) {
23+
w.WriteHeader(http.StatusNotModified)
24+
return
25+
}
26+
w.Header().Set("Last-Modified", mod.UTC().Format(http.TimeFormat))
27+
_, _ = w.Write([]byte(body))
28+
}))
29+
t.Cleanup(srv.Close)
30+
return srv
31+
}
32+
33+
// A runner started from a cached file older than the web copy fetches the web
34+
// copy, even when the process starts after the web copy was published. Before,
35+
// InitFrom recorded the start time as the data's age, so the first web check
36+
// asked only for data newer than the start, got 304, and kept the old file
37+
// until the web copy changed again.
38+
func TestInitFromOlderFileFetchesNewerWebCopy(t *testing.T) {
39+
published := time.Now().Add(-time.Hour).Truncate(time.Second)
40+
srv := lastModifiedServer(t, published, "new")
41+
42+
path := filepath.Join(t.TempDir(), "db")
43+
require.NoError(t, os.WriteFile(path, []byte("old"), 0644))
44+
cached := published.Add(-24 * time.Hour)
45+
require.NoError(t, os.Chtimes(path, cached, cached))
46+
47+
ch := make(chan []byte, 4)
48+
runner := New(FromWeb(srv.URL), ToFile(path), ToChannel(ch))
49+
runner.InitFrom(FromFile(path))
50+
assert.Equal(t, "old", string(<-ch), "InitFrom loads the cached file")
51+
stop := runner.Start(time.Hour)
52+
defer stop()
53+
54+
select {
55+
case got := <-ch:
56+
assert.Equal(t, "new", string(got), "the first web check fetches the newer copy")
57+
case <-time.After(5 * time.Second):
58+
t.Fatal("the runner never fetched the web copy published after its cached file")
59+
}
60+
b, err := os.ReadFile(path)
61+
require.NoError(t, err)
62+
assert.Equal(t, "new", string(b), "and saves it")
63+
}
64+
65+
// A runner started from a cached file at least as new as the web copy doesn't
66+
// fetch it again.
67+
func TestInitFromCurrentFileSkipsTheWebCopy(t *testing.T) {
68+
published := time.Now().Add(-time.Hour).Truncate(time.Second)
69+
srv := lastModifiedServer(t, published, "new")
70+
71+
path := filepath.Join(t.TempDir(), "db")
72+
require.NoError(t, os.WriteFile(path, []byte("cached"), 0644))
73+
cached := published.Add(time.Minute)
74+
require.NoError(t, os.Chtimes(path, cached, cached))
75+
76+
ch := make(chan []byte, 4)
77+
runner := New(FromWeb(srv.URL), ToFile(path), ToChannel(ch))
78+
runner.InitFrom(FromFile(path))
79+
assert.Equal(t, "cached", string(<-ch))
80+
stop := runner.Start(time.Hour)
81+
time.Sleep(200 * time.Millisecond)
82+
stop()
83+
select {
84+
case got := <-ch:
85+
t.Fatalf("fetched %q although the cached file is current", got)
86+
default:
87+
}
88+
}
89+
90+
// InitFrom doesn't write the file it read back to itself, so the file keeps
91+
// the age that tells the next runner whether it is current.
92+
func TestInitFromKeepsTheCachedFilesAge(t *testing.T) {
93+
path := filepath.Join(t.TempDir(), "db")
94+
require.NoError(t, os.WriteFile(path, []byte("cached"), 0644))
95+
cached := time.Now().Add(-48 * time.Hour).Truncate(time.Second)
96+
require.NoError(t, os.Chtimes(path, cached, cached))
97+
98+
ch := make(chan []byte, 1)
99+
runner := New(FromWeb("http://127.0.0.1:1/unused"), ToFile(path), ToChannel(ch))
100+
runner.InitFrom(FromFile(path))
101+
assert.Equal(t, "cached", string(<-ch))
102+
103+
fi, err := os.Stat(path)
104+
require.NoError(t, err)
105+
assert.True(t, fi.ModTime().Equal(cached), "modtime %v, want %v", fi.ModTime(), cached)
106+
}
107+
108+
// If-Modified-Since is the cutoff in GMT, whatever the host's time zone.
109+
func TestWebSourceSendsIfModifiedSinceInGMT(t *testing.T) {
110+
var got string
111+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+
got = r.Header.Get("If-Modified-Since")
113+
w.WriteHeader(http.StatusNotModified)
114+
}))
115+
t.Cleanup(srv.Close)
116+
cutoff := time.Date(2026, 10, 8, 17, 15, 0, 0, time.FixedZone("UTC-6", -6*3600))
117+
_, err := FromWeb(srv.URL).Fetch(cutoff)
118+
assert.ErrorIs(t, err, ErrUnmodified)
119+
assert.Equal(t, "Thu, 08 Oct 2026 23:15:00 GMT", got)
120+
}
121+
122+
// A sink that preprocesses still writes to the file the source read, since
123+
// its output can differ from what was read.
124+
func TestInitFromStillWritesAPreprocessedCopyInPlace(t *testing.T) {
125+
path := filepath.Join(t.TempDir(), "db")
126+
require.NoError(t, os.WriteFile(path, []byte("cached"), 0644))
127+
upper := func(r io.Reader) (io.Reader, error) {
128+
b, err := io.ReadAll(r)
129+
return strings.NewReader(strings.ToUpper(string(b))), err
130+
}
131+
runner := New(FromWeb("http://127.0.0.1:1/unused"), ToFileWithPreprocessor(path, upper))
132+
runner.InitFrom(FromFile(path))
133+
b, err := os.ReadFile(path)
134+
require.NoError(t, err)
135+
assert.Equal(t, "CACHED", string(b))
136+
}
137+
138+
// A cached file dated in the future says nothing about its age, so the first
139+
// web check is unconditional and fetches a copy published before that date.
140+
func TestInitFromFutureDatedFileFetchesTheWebCopy(t *testing.T) {
141+
published := time.Now().Add(-time.Hour).Truncate(time.Second)
142+
srv := lastModifiedServer(t, published, "new")
143+
144+
path := filepath.Join(t.TempDir(), "db")
145+
require.NoError(t, os.WriteFile(path, []byte("old"), 0644))
146+
future := time.Now().Add(48 * time.Hour)
147+
require.NoError(t, os.Chtimes(path, future, future))
148+
149+
ch := make(chan []byte, 4)
150+
runner := New(FromWeb(srv.URL), ToFile(path), ToChannel(ch))
151+
runner.InitFrom(FromFile(path))
152+
assert.Equal(t, "old", string(<-ch))
153+
stop := runner.Start(time.Hour)
154+
defer stop()
155+
select {
156+
case got := <-ch:
157+
assert.Equal(t, "new", string(got))
158+
case <-time.After(5 * time.Second):
159+
t.Fatal("a future-dated cached file kept the runner from fetching the web copy")
160+
}
161+
}
162+
163+
// A sink that reaches the source's file through a symlink is the same file, so
164+
// InitFrom doesn't rewrite it either.
165+
func TestInitFromKeepsTheAgeThroughASymlink(t *testing.T) {
166+
dir := t.TempDir()
167+
path := filepath.Join(dir, "db")
168+
link := filepath.Join(dir, "db-link")
169+
require.NoError(t, os.WriteFile(path, []byte("cached"), 0644))
170+
require.NoError(t, os.Symlink(path, link))
171+
cached := time.Now().Add(-48 * time.Hour).Truncate(time.Second)
172+
require.NoError(t, os.Chtimes(path, cached, cached))
173+
174+
ch := make(chan []byte, 1)
175+
runner := New(FromWeb("http://127.0.0.1:1/unused"), ToFile(link), ToChannel(ch))
176+
runner.InitFrom(FromFile(path))
177+
assert.Equal(t, "cached", string(<-ch))
178+
179+
fi, err := os.Lstat(link)
180+
require.NoError(t, err)
181+
assert.NotZero(t, fi.Mode()&os.ModeSymlink, "the link is still a link")
182+
fi, err = os.Stat(path)
183+
require.NoError(t, err)
184+
assert.True(t, fi.ModTime().Equal(cached), "modtime %v, want %v", fi.ModTime(), cached)
185+
}

‎keepcurrent.go‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,28 @@ func (runner *Runner) syncOnce(from Source, chStop chan struct{}) {
101101
if err == ErrUnmodified {
102102
return
103103
}
104+
updated := start
104105
if err == nil {
105106
// Read ahead to surface any error reading from the source. readAll
106107
// pre-sizes its buffer when the source reports a length, avoiding the
107108
// reallocation churn io.ReadAll incurs on large payloads.
108109
data, err = readAll(rc)
109110
rc.Close()
111+
// Data read from a file is as old as the file, not as the read. A
112+
// modification time after the read (clock skew, a copied file) says
113+
// nothing about the data's age, so the next check is unconditional.
114+
if m, ok := rc.(interface{ sourceModTime() time.Time }); ok {
115+
updated = m.sourceModTime()
116+
if updated.After(start) {
117+
updated = time.Time{}
118+
}
119+
}
110120
}
111121
if err == nil {
112122
err = runner.Validate(data)
113123
}
114124
if err == nil {
115-
runner.lastUpdated = start
125+
runner.lastUpdated = updated
116126
break
117127
}
118128
d := runner.OnSourceError(err, tries)
@@ -126,6 +136,9 @@ func (runner *Runner) syncOnce(from Source, chStop chan struct{}) {
126136
}
127137
}
128138
for _, s := range runner.sinks {
139+
if writesBack(from, s) {
140+
continue
141+
}
129142
if err := s.UpdateFrom(bytes.NewReader(data)); err != nil {
130143
runner.OnSinkError(s, err)
131144
}

‎source.go‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http"
99
"os"
1010
"path"
11+
"path/filepath"
1112
"sync"
1213
"time"
1314

@@ -46,7 +47,9 @@ func (s *webSource) Fetch(ifNewerThan time.Time) (io.ReadCloser, error) {
4647
return nil, err
4748
}
4849
if !ifNewerThan.IsZero() {
49-
req.Header.Add("If-Modified-Since", ifNewerThan.Format(http.TimeFormat))
50+
// http.TimeFormat is GMT, so the time must be in UTC first; formatted in
51+
// local time it would claim a different instant on any host not on UTC.
52+
req.Header.Add("If-Modified-Since", ifNewerThan.UTC().Format(http.TimeFormat))
5053
}
5154
if s.getETag() != "" {
5255
req.Header.Add("If-None-Match", s.etag)
@@ -211,11 +214,58 @@ func (s *fileSource) Fetch(ifNewerThan time.Time) (io.ReadCloser, error) {
211214
// The preprocessor may hand back a stream that doesn't close the file it
212215
// wraps (e.g. io.NopCloser), so couple Close to the underlying file to
213216
// avoid leaking the descriptor.
214-
return fileBackedReadCloser{ReadCloser: result, f: f}, nil
217+
return modTimeReadCloser{ReadCloser: fileBackedReadCloser{ReadCloser: result, f: f}, mod: fi.ModTime()}, nil
215218
}
216219
// Surface the file size so the Runner's readAll pre-sizes its buffer instead
217220
// of growing by reallocation. The cached payloads read here are exactly the
218221
// large ones that churn — e.g. geo loads a ~75MB MaxMind mmdb from disk via
219222
// InitFrom(FromFile(...)) at startup.
220-
return sizedReadCloser{ReadCloser: f, n: fi.Size()}, nil
223+
return modTimeReadCloser{ReadCloser: sizedReadCloser{ReadCloser: f, n: fi.Size()}, mod: fi.ModTime()}, nil
224+
}
225+
226+
// modTimeReadCloser is a file's contents with the file's modification time, so
227+
// a Runner records how old the data it loaded is rather than when it loaded it.
228+
// Without this, a Runner initialised from an older cached file would ask its
229+
// web source only for data newer than the moment it started, and never fetch
230+
// an update published between the file's download and that moment.
231+
type modTimeReadCloser struct {
232+
io.ReadCloser
233+
mod time.Time
234+
}
235+
236+
func (r modTimeReadCloser) sourceModTime() time.Time { return r.mod }
237+
238+
// size passes through the wrapped reader's size, or -1 (unknown) when it has
239+
// none, so readAll still pre-sizes a plain file read.
240+
func (r modTimeReadCloser) size() int64 {
241+
if n, ok := knownSize(r.ReadCloser); ok {
242+
return n
243+
}
244+
return -1
245+
}
246+
247+
// writesBack reports whether sink s would copy source from's file onto itself
248+
// unchanged, which a sync skips: the rewrite changes nothing but the file's
249+
// modification time, which moves to now, so the next Runner to start from it
250+
// would take an old file for a fresh one. A preprocessor on either side can
251+
// change the contents, so those writes still happen.
252+
func writesBack(from Source, s Sink) bool {
253+
src, ok := from.(*fileSource)
254+
if !ok || src.preprocessor != nil {
255+
return false
256+
}
257+
dst, ok := s.(*fileSink)
258+
if !ok || dst.preprocessor != nil {
259+
return false
260+
}
261+
// Two paths can name one file (a symlink, a relative path): compare the
262+
// files themselves when both exist.
263+
if srcInfo, err := os.Stat(src.path); err == nil {
264+
if dstInfo, err := os.Stat(dst.path); err == nil {
265+
return os.SameFile(srcInfo, dstInfo)
266+
}
267+
}
268+
a, errA := filepath.Abs(src.path)
269+
b, errB := filepath.Abs(dst.path)
270+
return errA == nil && errB == nil && a == b
221271
}

0 commit comments

Comments
 (0)