detached 7c59f07

feat(filtering): implement 24-hour cache staleness check and background update

commit
7c59f078c126e0bf7b90385fee355af9956ced0d
author
brice · · 2025-12-22 17:39 UTC
committer
brice · · 2025-12-22 18:02 UTC
parent
457d11f
signature
unsigned
4 files · +87 -0
M internal/infrastructure/filtering/downloader.go +11 -0
@@ -286,3 +286,14 @@ func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) {
286286 func (d *Downloader) ClearCache() error {
287287 return os.RemoveAll(d.cacheDir)
288288 }
289+
290+// IsCacheStale checks if the cached manifest is older than maxAge.
291+// Returns true if cache is stale or doesn't exist.
292+func (d *Downloader) IsCacheStale(maxAge time.Duration) bool {
293+ path := filepath.Join(d.cacheDir, FilterFiles.Manifest)
294+ info, err := os.Stat(path)
295+ if err != nil {
296+ return true
297+ }
298+ return time.Since(info.ModTime()) > maxAge
299+}
@@ -286,3 +286,14 @@ func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) {
286 func (d *Downloader) ClearCache() error {286 func (d *Downloader) ClearCache() error {
287 return os.RemoveAll(d.cacheDir)287 return os.RemoveAll(d.cacheDir)
288 }288 }
289+
290+// IsCacheStale checks if the cached manifest is older than maxAge.
291+// Returns true if cache is stale or doesn't exist.
292+func (d *Downloader) IsCacheStale(maxAge time.Duration) bool {
293+ path := filepath.Join(d.cacheDir, FilterFiles.Manifest)
294+ info, err := os.Stat(path)
295+ if err != nil {
296+ return true
297+ }
298+ return time.Since(info.ModTime()) > maxAge
299+}
M internal/infrastructure/filtering/interfaces.go +4 -0
@@ -2,6 +2,7 @@ package filtering
22
33 import (
44 "context"
5+ "time"
56
67 "github.com/bnema/puregotk-webkit/webkit"
78 )
@@ -51,6 +52,9 @@ type FilterDownloader interface {
5152
5253 // GetCachedFilterPaths returns paths to cached filter JSON files.
5354 GetCachedFilterPaths() []string
55+
56+ // IsCacheStale checks if the cached manifest is older than maxAge.
57+ IsCacheStale(maxAge time.Duration) bool
5458 }
5559
5660 // Ensure concrete types implement the interfaces.
@@ -2,6 +2,7 @@ package filtering
2 2
3 import (3 import (
4 "context"4 "context"
5+ "time"
5 6
6 "github.com/bnema/puregotk-webkit/webkit"7 "github.com/bnema/puregotk-webkit/webkit"
7 )8 )
@@ -51,6 +52,9 @@ type FilterDownloader interface {
51 52
52 // GetCachedFilterPaths returns paths to cached filter JSON files.53 // GetCachedFilterPaths returns paths to cached filter JSON files.
53 GetCachedFilterPaths() []string54 GetCachedFilterPaths() []string
55+
56+ // IsCacheStale checks if the cached manifest is older than maxAge.
57+ IsCacheStale(maxAge time.Duration) bool
54 }58 }
55 59
56 // Ensure concrete types implement the interfaces.60 // Ensure concrete types implement the interfaces.
M internal/infrastructure/filtering/manager.go +20 -0
@@ -8,6 +8,7 @@ import (
88 "path/filepath"
99 "sync"
1010 "sync/atomic"
11+ "time"
1112
1213 "github.com/bnema/dumber/internal/logging"
1314 "github.com/bnema/puregotk-webkit/webkit"
@@ -17,6 +18,9 @@ const (
1718 storeDirPerm = 0o755
1819 jsonDirPerm = 0o755
1920 mergedFilePerm = 0o644
21+
22+ // CacheMaxAge is the maximum age of the filter cache before it's considered stale.
23+ CacheMaxAge = 24 * time.Hour
2024 )
2125
2226 // Manager orchestrates the content filter lifecycle.
@@ -167,6 +171,8 @@ func (m *Manager) LoadAsync(ctx context.Context) {
167171 Version: version,
168172 })
169173 log.Info().Str("version", version).Msg("filters loaded from cache")
174+
175+ m.checkStaleCacheAndUpdate(ctx)
170176 return
171177 }
172178 log.Warn().Err(err).Msg("failed to load cached filter, will download")
@@ -257,6 +263,20 @@ func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, e
257263 return mergedPath, nil
258264 }
259265
266+// checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
267+func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
268+ if !m.downloader.IsCacheStale(CacheMaxAge) {
269+ return
270+ }
271+ log := logging.FromContext(ctx)
272+ log.Info().Msg("filter cache is stale, checking for updates in background")
273+ go func() {
274+ if err := m.CheckForUpdates(ctx); err != nil {
275+ log.Warn().Err(err).Msg("background filter update check failed")
276+ }
277+ }()
278+}
279+
260280 // getCachedVersion returns the version from the cached manifest.
261281 func (m *Manager) getCachedVersion() string {
262282 manifest, err := m.downloader.GetCachedManifest()
@@ -8,6 +8,7 @@ import (
8 "path/filepath"8 "path/filepath"
9 "sync"9 "sync"
10 "sync/atomic"10 "sync/atomic"
11+ "time"
11 12
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk-webkit/webkit"14 "github.com/bnema/puregotk-webkit/webkit"
@@ -17,6 +18,9 @@ const (
17 storeDirPerm = 0o75518 storeDirPerm = 0o755
18 jsonDirPerm = 0o75519 jsonDirPerm = 0o755
19 mergedFilePerm = 0o64420 mergedFilePerm = 0o644
21+
22+ // CacheMaxAge is the maximum age of the filter cache before it's considered stale.
23+ CacheMaxAge = 24 * time.Hour
20 )24 )
21 25
22 // Manager orchestrates the content filter lifecycle.26 // Manager orchestrates the content filter lifecycle.
@@ -167,6 +171,8 @@ func (m *Manager) LoadAsync(ctx context.Context) {
167 Version: version,171 Version: version,
168 })172 })
169 log.Info().Str("version", version).Msg("filters loaded from cache")173 log.Info().Str("version", version).Msg("filters loaded from cache")
174+
175+ m.checkStaleCacheAndUpdate(ctx)
170 return176 return
171 }177 }
172 log.Warn().Err(err).Msg("failed to load cached filter, will download")178 log.Warn().Err(err).Msg("failed to load cached filter, will download")
@@ -257,6 +263,20 @@ func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, e
257 return mergedPath, nil263 return mergedPath, nil
258 }264 }
259 265
266+// checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
267+func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
268+ if !m.downloader.IsCacheStale(CacheMaxAge) {
269+ return
270+ }
271+ log := logging.FromContext(ctx)
272+ log.Info().Msg("filter cache is stale, checking for updates in background")
273+ go func() {
274+ if err := m.CheckForUpdates(ctx); err != nil {
275+ log.Warn().Err(err).Msg("background filter update check failed")
276+ }
277+ }()
278+}
279+
260 // getCachedVersion returns the version from the cached manifest.280 // getCachedVersion returns the version from the cached manifest.
261 func (m *Manager) getCachedVersion() string {281 func (m *Manager) getCachedVersion() string {
262 manifest, err := m.downloader.GetCachedManifest()282 manifest, err := m.downloader.GetCachedManifest()
M internal/infrastructure/filtering/mocks/mocks.go +52 -0
@@ -6,6 +6,7 @@ package mocks
66
77 import (
88 "context"
9+ "time"
910
1011 "github.com/bnema/dumber/internal/infrastructure/filtering"
1112 "github.com/bnema/puregotk-webkit/webkit"
@@ -747,6 +748,57 @@ func (_c *MockFilterDownloader_HasCachedFilters_Call) RunAndReturn(run func() bo
747748 return _c
748749 }
749750
751+// IsCacheStale provides a mock function for the type MockFilterDownloader
752+func (_mock *MockFilterDownloader) IsCacheStale(maxAge time.Duration) bool {
753+ ret := _mock.Called(maxAge)
754+
755+ if len(ret) == 0 {
756+ panic("no return value specified for IsCacheStale")
757+ }
758+
759+ var r0 bool
760+ if returnFunc, ok := ret.Get(0).(func(time.Duration) bool); ok {
761+ r0 = returnFunc(maxAge)
762+ } else {
763+ r0 = ret.Get(0).(bool)
764+ }
765+ return r0
766+}
767+
768+// MockFilterDownloader_IsCacheStale_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsCacheStale'
769+type MockFilterDownloader_IsCacheStale_Call struct {
770+ *mock.Call
771+}
772+
773+// IsCacheStale is a helper method to define mock.On call
774+// - maxAge time.Duration
775+func (_e *MockFilterDownloader_Expecter) IsCacheStale(maxAge interface{}) *MockFilterDownloader_IsCacheStale_Call {
776+ return &MockFilterDownloader_IsCacheStale_Call{Call: _e.mock.On("IsCacheStale", maxAge)}
777+}
778+
779+func (_c *MockFilterDownloader_IsCacheStale_Call) Run(run func(maxAge time.Duration)) *MockFilterDownloader_IsCacheStale_Call {
780+ _c.Call.Run(func(args mock.Arguments) {
781+ var arg0 time.Duration
782+ if args[0] != nil {
783+ arg0 = args[0].(time.Duration)
784+ }
785+ run(
786+ arg0,
787+ )
788+ })
789+ return _c
790+}
791+
792+func (_c *MockFilterDownloader_IsCacheStale_Call) Return(b bool) *MockFilterDownloader_IsCacheStale_Call {
793+ _c.Call.Return(b)
794+ return _c
795+}
796+
797+func (_c *MockFilterDownloader_IsCacheStale_Call) RunAndReturn(run func(maxAge time.Duration) bool) *MockFilterDownloader_IsCacheStale_Call {
798+ _c.Call.Return(run)
799+ return _c
800+}
801+
750802 // NeedsUpdate provides a mock function for the type MockFilterDownloader
751803 func (_mock *MockFilterDownloader) NeedsUpdate(ctx context.Context) (bool, error) {
752804 ret := _mock.Called(ctx)
@@ -6,6 +6,7 @@ package mocks
6 6
7 import (7 import (
8 "context"8 "context"
9+ "time"
9 10
10 "github.com/bnema/dumber/internal/infrastructure/filtering"11 "github.com/bnema/dumber/internal/infrastructure/filtering"
11 "github.com/bnema/puregotk-webkit/webkit"12 "github.com/bnema/puregotk-webkit/webkit"
@@ -747,6 +748,57 @@ func (_c *MockFilterDownloader_HasCachedFilters_Call) RunAndReturn(run func() bo
747 return _c748 return _c
748 }749 }
749 750
751+// IsCacheStale provides a mock function for the type MockFilterDownloader
752+func (_mock *MockFilterDownloader) IsCacheStale(maxAge time.Duration) bool {
753+ ret := _mock.Called(maxAge)
754+
755+ if len(ret) == 0 {
756+ panic("no return value specified for IsCacheStale")
757+ }
758+
759+ var r0 bool
760+ if returnFunc, ok := ret.Get(0).(func(time.Duration) bool); ok {
761+ r0 = returnFunc(maxAge)
762+ } else {
763+ r0 = ret.Get(0).(bool)
764+ }
765+ return r0
766+}
767+
768+// MockFilterDownloader_IsCacheStale_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsCacheStale'
769+type MockFilterDownloader_IsCacheStale_Call struct {
770+ *mock.Call
771+}
772+
773+// IsCacheStale is a helper method to define mock.On call
774+// - maxAge time.Duration
775+func (_e *MockFilterDownloader_Expecter) IsCacheStale(maxAge interface{}) *MockFilterDownloader_IsCacheStale_Call {
776+ return &MockFilterDownloader_IsCacheStale_Call{Call: _e.mock.On("IsCacheStale", maxAge)}
777+}
778+
779+func (_c *MockFilterDownloader_IsCacheStale_Call) Run(run func(maxAge time.Duration)) *MockFilterDownloader_IsCacheStale_Call {
780+ _c.Call.Run(func(args mock.Arguments) {
781+ var arg0 time.Duration
782+ if args[0] != nil {
783+ arg0 = args[0].(time.Duration)
784+ }
785+ run(
786+ arg0,
787+ )
788+ })
789+ return _c
790+}
791+
792+func (_c *MockFilterDownloader_IsCacheStale_Call) Return(b bool) *MockFilterDownloader_IsCacheStale_Call {
793+ _c.Call.Return(b)
794+ return _c
795+}
796+
797+func (_c *MockFilterDownloader_IsCacheStale_Call) RunAndReturn(run func(maxAge time.Duration) bool) *MockFilterDownloader_IsCacheStale_Call {
798+ _c.Call.Return(run)
799+ return _c
800+}
801+
750 // NeedsUpdate provides a mock function for the type MockFilterDownloader802 // NeedsUpdate provides a mock function for the type MockFilterDownloader
751 func (_mock *MockFilterDownloader) NeedsUpdate(ctx context.Context) (bool, error) {803 func (_mock *MockFilterDownloader) NeedsUpdate(ctx context.Context) (bool, error) {
752 ret := _mock.Called(ctx)804 ret := _mock.Called(ctx)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close