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) { | |||
| 286 | 286 | func (d *Downloader) ClearCache() error { | |
| 287 | 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 | + | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/puregotk-webkit/webkit" | |
| 7 | 8 | ) | |
| @@ -51,6 +52,9 @@ type FilterDownloader interface { | |||
| 51 | 52 | ||
| 52 | 53 | // GetCachedFilterPaths returns paths to cached filter JSON files. | |
| 53 | 54 | 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 | 60 | // 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() []string | 54 | 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 ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "sync" | |
| 10 | 10 | "sync/atomic" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 14 | "github.com/bnema/puregotk-webkit/webkit" | |
| @@ -17,6 +18,9 @@ const ( | |||
| 17 | 18 | storeDirPerm = 0o755 | |
| 18 | 19 | jsonDirPerm = 0o755 | |
| 19 | 20 | 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 | 26 | // Manager orchestrates the content filter lifecycle. | |
| @@ -167,6 +171,8 @@ func (m *Manager) LoadAsync(ctx context.Context) { | |||
| 167 | 171 | Version: version, | |
| 168 | 172 | }) | |
| 169 | 173 | log.Info().Str("version", version).Msg("filters loaded from cache") | |
| 174 | + | ||
| 175 | + | m.checkStaleCacheAndUpdate(ctx) | |
| 170 | 176 | return | |
| 171 | 177 | } | |
| 172 | 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 | 263 | 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 | 280 | // getCachedVersion returns the version from the cached manifest. | |
| 261 | 281 | func (m *Manager) getCachedVersion() string { | |
| 262 | 282 | 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 = 0o755 | 18 | storeDirPerm = 0o755 | ||
| 18 | jsonDirPerm = 0o755 | 19 | jsonDirPerm = 0o755 | ||
| 19 | mergedFilePerm = 0o644 | 20 | 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 | return | 176 | 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, nil | 263 | 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 | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "context" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 11 | 12 | "github.com/bnema/puregotk-webkit/webkit" | |
| @@ -747,6 +748,57 @@ func (_c *MockFilterDownloader_HasCachedFilters_Call) RunAndReturn(run func() bo | |||
| 747 | 748 | 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 | 802 | // NeedsUpdate provides a mock function for the type MockFilterDownloader | |
| 751 | 803 | func (_mock *MockFilterDownloader) NeedsUpdate(ctx context.Context) (bool, error) { | |
| 752 | 804 | 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 _c | 748 | 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 MockFilterDownloader | 802 | // 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) | ||