1- using Ifpa . Models ;
1+ using Ifpa . Models ;
2+ using Microsoft . Extensions . Logging ;
23using Polly . Caching ;
34using SQLite ;
45using System . Text . Json ;
@@ -7,41 +8,57 @@ namespace Ifpa.Caching
78{
89 public class SQLiteCacheProvider < T > : IAsyncCacheProvider < T > , IAsyncDisposable
910 {
10- private readonly SQLiteAsyncConnection _db ;
11+ private readonly string _dbPath ;
12+ private readonly ILogger < SQLiteCacheProvider < T > > _logger ;
13+ private SQLiteAsyncConnection _db ;
1114
12- public SQLiteCacheProvider ( string dbPath )
15+ public SQLiteCacheProvider ( string dbPath , ILoggerFactory loggerFactory = null )
1316 {
17+ _dbPath = dbPath ;
18+ _logger = loggerFactory ? . CreateLogger < SQLiteCacheProvider < T > > ( ) ;
1419 _db = new SQLiteAsyncConnection ( dbPath ) ;
15- Task . Run ( ( ) => _db . CreateTableAsync < CacheItem > ( ) ) . Wait ( ) ;
16- Task . Run ( CleanupExpiredItems ) . Wait ( ) ; // Remove expired items on initialization
20+ // Initialize synchronously so a usable connection is guaranteed before first use.
21+ // A corrupt cache file self-heals here rather than throwing and bricking every cached call.
22+ Task . Run ( InitializeAsync ) . GetAwaiter ( ) . GetResult ( ) ;
1723 }
1824
25+ private Task InitializeAsync ( ) =>
26+ RunWithRecovery ( async ( ) =>
27+ {
28+ await _db . CreateTableAsync < CacheItem > ( ) ;
29+ await CleanupExpiredItems ( ) ; // Remove expired items on initialization
30+ } ) ;
31+
1932 private async Task CleanupExpiredItems ( )
2033 {
2134 await _db . Table < CacheItem > ( ) . Where ( item => item . Expiration <= DateTime . UtcNow ) . DeleteAsync ( ) ;
2235 }
2336
2437 public async Task RemoveAsync ( string key , CancellationToken cancellationToken = default )
2538 {
26- await _db . DeleteAsync < CacheItem > ( key ) ;
39+ await RunWithRecovery ( ( ) => _db . DeleteAsync < CacheItem > ( key ) ) ;
2740 }
2841
29- public async Task < ( bool , T ) > TryGetAsync ( string key , CancellationToken cancellationToken , bool continueOnCapturedContext )
42+ public Task < ( bool , T ) > TryGetAsync ( string key , CancellationToken cancellationToken , bool continueOnCapturedContext )
3043 {
31- await CleanupExpiredItems ( ) ; // Clean expired items before fetching
32- var item = await _db . FindAsync < CacheItem > ( key ) ;
33-
34- if ( item != null && item . Expiration > DateTime . UtcNow )
44+ return RunWithRecovery ( async ( ) =>
3545 {
36- // Deserialize to an object since type is not known at compile time
37- var value = JsonSerializer . Deserialize < T > ( item . Value ) ;
38- return ( true , value ) ;
39- }
46+ // Expired rows are purged once per provider construction (InitializeAsync); the
47+ // expiration check below still guarantees an expired entry is never returned.
48+ var item = await _db . FindAsync < CacheItem > ( key ) ;
4049
41- return ( false , default ( T ) ) ; // Return false and null if no valid cache item is found
50+ if ( item != null && item . Expiration > DateTime . UtcNow )
51+ {
52+ // Deserialize to an object since type is not known at compile time
53+ var value = JsonSerializer . Deserialize < T > ( item . Value ) ;
54+ return ( true , value ) ;
55+ }
56+
57+ return ( false , default ( T ) ) ; // Return false and null if no valid cache item is found
58+ } ) ;
4259 }
4360
44- public async Task PutAsync ( string key , T value , Ttl ttl , CancellationToken cancellationToken , bool continueOnCapturedContext )
61+ public Task PutAsync ( string key , T value , Ttl ttl , CancellationToken cancellationToken , bool continueOnCapturedContext )
4562 {
4663 if ( ttl . Timespan > Settings . CacheDuration )
4764 ttl . Timespan = Settings . CacheDuration ;
@@ -53,22 +70,92 @@ public async Task PutAsync(string key, T value, Ttl ttl, CancellationToken cance
5370 Expiration = DateTime . UtcNow . Add ( ttl . Timespan )
5471 } ;
5572
56- await _db . InsertOrReplaceAsync ( item ) ;
57- await CleanupExpiredItems ( ) ; // Enforce cleanup after insertion
73+ return RunWithRecovery ( ( ) => _db . InsertOrReplaceAsync ( item ) ) ;
5874 }
5975
60- public async Task ClearCache ( )
76+ public Task ClearCache ( )
6177 {
62- // Delete all entries
63- await _db . DeleteAllAsync < CacheItem > ( ) ;
64- // Run VACUUM to reclaim space and optimize the database
65- await _db . ExecuteAsync ( "VACUUM" ) ;
78+ return RunWithRecovery ( async ( ) =>
79+ {
80+ // Delete all entries
81+ await _db . DeleteAllAsync < CacheItem > ( ) ;
82+ // Run VACUUM to reclaim space and optimize the database
83+ await _db . ExecuteAsync ( "VACUUM" ) ;
84+ } ) ;
6685 }
6786
6887 public async ValueTask DisposeAsync ( )
6988 {
7089 await _db . CloseAsync ( ) ;
7190 }
91+
92+ // Runs a cache operation, transparently rebuilding the database once if it is found to be
93+ // corrupt. The cache holds only re-fetchable API responses, so discarding it is always safe.
94+ private Task RunWithRecovery ( Func < Task > operation ) =>
95+ RunWithRecovery < object > ( async ( ) => { await operation ( ) ; return null ; } ) ;
96+
97+ private async Task < TResult > RunWithRecovery < TResult > ( Func < Task < TResult > > operation )
98+ {
99+ try
100+ {
101+ return await operation ( ) ;
102+ }
103+ catch ( Exception ex ) when ( IsCorruptionException ( ex ) )
104+ {
105+ _logger ? . LogWarning ( ex ,
106+ "Cache database at {Path} is corrupt; rebuilding and retrying. Cached API data will be re-fetched; no user data is affected." ,
107+ _dbPath ) ;
108+ await RecreateDatabaseAsync ( ) ;
109+ return await operation ( ) ;
110+ }
111+ }
112+
113+ private async Task RecreateDatabaseAsync ( )
114+ {
115+ // Release our handle so the underlying file can be deleted.
116+ try { await _db . CloseAsync ( ) ; }
117+ catch ( Exception ex ) { _logger ? . LogDebug ( ex , "Error closing corrupt cache connection (ignored)" ) ; }
118+
119+ DeleteDatabaseFiles ( ) ;
120+
121+ // Fresh, empty database with the schema in place.
122+ _db = new SQLiteAsyncConnection ( _dbPath ) ;
123+ await _db . CreateTableAsync < CacheItem > ( ) ;
124+ }
125+
126+ private void DeleteDatabaseFiles ( )
127+ {
128+ // SQLite may keep sidecar files (WAL / shared-memory / rollback journal); remove them all.
129+ foreach ( var suffix in new [ ] { string . Empty , "-wal" , "-shm" , "-journal" } )
130+ {
131+ var path = _dbPath + suffix ;
132+ try
133+ {
134+ if ( File . Exists ( path ) )
135+ File . Delete ( path ) ;
136+ }
137+ catch ( Exception ex )
138+ {
139+ _logger ? . LogWarning ( ex , "Could not delete corrupt cache file {Path}" , path ) ;
140+ }
141+ }
142+ }
143+
144+ private static bool IsCorruptionException ( Exception ex )
145+ {
146+ switch ( ex )
147+ {
148+ case SQLiteException sqlite :
149+ // SQLITE_CORRUPT ("database disk image is malformed") or SQLITE_NOTADB.
150+ return sqlite . Result == SQLite3 . Result . Corrupt
151+ || sqlite . Result == SQLite3 . Result . NonDBFile ;
152+ case AggregateException aggregate :
153+ // .Wait()/.GetResult() wrap the SQLiteException; unwrap and inspect.
154+ return aggregate . Flatten ( ) . InnerExceptions . Any ( IsCorruptionException ) ;
155+ default :
156+ return ex . InnerException != null && IsCorruptionException ( ex . InnerException ) ;
157+ }
158+ }
72159 }
73160
74161 public class CacheItem
0 commit comments