Skip to content

Commit 7a78ea7

Browse files
authored
fix: implement in-memory caching for resumable uploads, optimize erro… (#35)
* fix: implement in-memory caching for resumable uploads, optimize error handling, and refactor upload progress reporting. * fix: add remove from cache after complete
1 parent baab26d commit 7a78ea7

File tree

4 files changed

+230
-32
lines changed

4 files changed

+230
-32
lines changed

Storage/Extensions/HttpClientProgress.cs

Lines changed: 85 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
using System.Threading.Tasks;
77
using BirdMessenger;
88
using BirdMessenger.Collections;
9+
using BirdMessenger.Delegates;
10+
using BirdMessenger.Infrastructure;
911
using Newtonsoft.Json;
1012
using Supabase.Storage.Exceptions;
1113

@@ -175,7 +177,7 @@ public static async Task<HttpResponseMessage> UploadAsync(
175177
}
176178
}
177179

178-
var response = await client.PostAsync(uri, content, cancellationToken);
180+
var response = await client.PostAsync(uri, content, cancellationToken);
179181

180182
if (!response.IsSuccessStatusCode)
181183
{
@@ -199,8 +201,8 @@ public static Task<HttpResponseMessage> UploadOrContinueFileAsync(
199201
this HttpClient client,
200202
Uri uri,
201203
string filePath,
204+
MetadataCollection metadata,
202205
Dictionary<string, string>? headers = null,
203-
MetadataCollection? metadata = null,
204206
Progress<float>? progress = null,
205207
CancellationToken cancellationToken = default
206208
)
@@ -210,8 +212,8 @@ public static Task<HttpResponseMessage> UploadOrContinueFileAsync(
210212
client,
211213
uri,
212214
fileStream,
213-
headers,
214215
metadata,
216+
headers,
215217
progress,
216218
cancellationToken
217219
);
@@ -221,8 +223,8 @@ public static Task<HttpResponseMessage> UploadOrContinueByteAsync(
221223
this HttpClient client,
222224
Uri uri,
223225
byte[] data,
226+
MetadataCollection metadata,
224227
Dictionary<string, string>? headers = null,
225-
MetadataCollection? metadata = null,
226228
Progress<float>? progress = null,
227229
CancellationToken cancellationToken = default
228230
)
@@ -232,8 +234,8 @@ public static Task<HttpResponseMessage> UploadOrContinueByteAsync(
232234
client,
233235
uri,
234236
stream,
235-
headers,
236237
metadata,
238+
headers,
237239
progress,
238240
cancellationToken
239241
);
@@ -243,8 +245,8 @@ private static async Task<HttpResponseMessage> ResumableUploadAsync(
243245
this HttpClient client,
244246
Uri uri,
245247
Stream fileStream,
248+
MetadataCollection metadata,
246249
Dictionary<string, string>? headers = null,
247-
MetadataCollection? metadata = null,
248250
IProgress<float>? progress = null,
249251
CancellationToken cancellationToken = default
250252
)
@@ -266,32 +268,51 @@ private static async Task<HttpResponseMessage> ResumableUploadAsync(
266268
}
267269
}
268270

269-
var createOption = new TusCreateRequestOption()
271+
var cacheKey =
272+
$"{metadata["bucketName"]}/{metadata["objectName"]}/{metadata["contentType"]}";
273+
274+
UploadMemoryCache.TryGet(cacheKey, out var upload);
275+
Uri? fileLocation = null;
276+
if (upload == null)
270277
{
271-
Endpoint = uri,
272-
Metadata = metadata,
273-
UploadLength = fileStream.Length,
274-
};
278+
var createOption = new TusCreateRequestOption()
279+
{
280+
Endpoint = uri,
281+
Metadata = metadata,
282+
UploadLength = fileStream.Length,
283+
};
275284

276-
var responseCreate = await client.TusCreateAsync(createOption, cancellationToken);
285+
try
286+
{
287+
var responseCreate = await client.TusCreateAsync(
288+
createOption,
289+
cancellationToken
290+
);
291+
292+
fileLocation = responseCreate.FileLocation;
293+
UploadMemoryCache.Set(cacheKey, fileLocation.ToString());
294+
}
295+
catch (TusException error)
296+
{
297+
throw await HandleResponseError(error);
298+
}
299+
}
300+
301+
if (upload != null)
302+
fileLocation = new Uri(upload);
277303

278304
var patchOption = new TusPatchRequestOption
279305
{
280-
FileLocation = responseCreate.FileLocation,
306+
FileLocation = fileLocation,
281307
Stream = fileStream,
282308
UploadBufferSize = 6 * 1024 * 1024,
283309
UploadType = UploadType.Chunk,
284-
OnProgressAsync = x =>
310+
OnProgressAsync = x => ReportProgressAsync(progress, x),
311+
OnCompletedAsync = _ =>
285312
{
286-
if (progress == null)
287-
return Task.CompletedTask;
288-
289-
var uploadedProgress = (float)x.UploadedSize / x.TotalSize * 100f;
290-
progress.Report(uploadedProgress);
291-
313+
UploadMemoryCache.Remove(cacheKey);
292314
return Task.CompletedTask;
293315
},
294-
OnCompletedAsync = _ => Task.CompletedTask,
295316
OnFailedAsync = _ => Task.CompletedTask,
296317
};
297318

@@ -300,19 +321,54 @@ private static async Task<HttpResponseMessage> ResumableUploadAsync(
300321
if (responsePatch.OriginResponseMessage.IsSuccessStatusCode)
301322
return responsePatch.OriginResponseMessage;
302323

303-
var httpContent = await responsePatch.OriginResponseMessage.Content.ReadAsStringAsync();
324+
throw await HandleResponseError(responsePatch.OriginResponseMessage);
325+
}
326+
327+
private static Task ReportProgressAsync(
328+
IProgress<float>? progress,
329+
UploadProgressEvent progressInfo
330+
)
331+
{
332+
if (progress == null)
333+
return Task.CompletedTask;
334+
335+
var uploadedProgress = (float)progressInfo.UploadedSize / progressInfo.TotalSize * 100f;
336+
progress.Report(uploadedProgress);
337+
338+
return Task.CompletedTask;
339+
}
340+
341+
private static async Task<SupabaseStorageException> HandleResponseError(
342+
HttpResponseMessage response
343+
)
344+
{
345+
var httpContent = await response.Content.ReadAsStringAsync();
304346
var errorResponse = JsonConvert.DeserializeObject<ErrorResponse>(httpContent);
305-
var e = new SupabaseStorageException(errorResponse?.Message ?? httpContent)
347+
var error = new SupabaseStorageException(errorResponse?.Message ?? httpContent)
348+
{
349+
Content = httpContent,
350+
Response = response,
351+
StatusCode = errorResponse?.StatusCode ?? (int)response.StatusCode,
352+
};
353+
error.AddReason();
354+
355+
return error;
356+
}
357+
358+
private static async Task<SupabaseStorageException> HandleResponseError(
359+
TusException response
360+
)
361+
{
362+
var httpContent = await response.OriginHttpResponse.Content.ReadAsStringAsync();
363+
var error = new SupabaseStorageException(httpContent)
306364
{
307365
Content = httpContent,
308-
Response = responsePatch.OriginResponseMessage,
309-
StatusCode =
310-
errorResponse?.StatusCode
311-
?? (int)responsePatch.OriginResponseMessage.StatusCode,
366+
Response = response.OriginHttpResponse,
367+
StatusCode = (int)response.OriginHttpResponse.StatusCode,
312368
};
369+
error.AddReason();
313370

314-
e.AddReason();
315-
throw e;
371+
return error;
316372
}
317373
}
318374
}

Storage/StorageFileApi.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,8 +744,8 @@ private async Task UploadOrContinue(
744744
await Helpers.HttpUploadClient!.UploadOrContinueFileAsync(
745745
uri,
746746
localPath,
747-
headers,
748747
metadata,
748+
headers,
749749
progress,
750750
cancellationToken
751751
);
@@ -792,8 +792,8 @@ private async Task UploadOrContinue(
792792
await Helpers.HttpUploadClient!.UploadOrContinueByteAsync(
793793
uri,
794794
data,
795-
headers,
796795
metadata,
796+
headers,
797797
progress,
798798
cancellationToken
799799
);

Storage/UploadMemoryCache.cs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using System;
2+
using System.Collections.Concurrent;
3+
using System.Threading;
4+
5+
namespace Supabase.Storage;
6+
7+
/// <summary>
8+
/// Provides thread-safe in-memory caching for resumable upload URLs with sliding expiration.
9+
/// </summary>
10+
public class UploadMemoryCache
11+
{
12+
private static readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
13+
14+
private static TimeSpan _defaultTtl = TimeSpan.FromMinutes(60);
15+
16+
private static long _version; // helps with testing/observability if needed
17+
18+
private sealed class CacheEntry
19+
{
20+
public string Url { get; }
21+
public DateTimeOffset Expiration { get; private set; }
22+
public TimeSpan Ttl { get; }
23+
24+
public CacheEntry(string url, TimeSpan ttl)
25+
{
26+
Url = url;
27+
Ttl = ttl <= TimeSpan.Zero ? TimeSpan.FromMinutes(5) : ttl;
28+
Touch();
29+
}
30+
31+
public void Touch()
32+
{
33+
Expiration = DateTimeOffset.UtcNow.Add(Ttl);
34+
}
35+
36+
public bool IsExpired() => DateTimeOffset.UtcNow >= Expiration;
37+
}
38+
39+
/// <summary>
40+
/// Sets the default time-to-live duration for future cache entries.
41+
/// </summary>
42+
/// <param name="ttl">The time-to-live duration. If less than or equal to zero, defaults to 5 minutes.</param>
43+
public static void SetDefaultTtl(TimeSpan ttl)
44+
{
45+
_defaultTtl = ttl <= TimeSpan.Zero ? TimeSpan.FromMinutes(5) : ttl;
46+
}
47+
48+
// Store or upate the resumable upload URL for the provided key.
49+
/// <summary>
50+
/// Stores or updates a resumable upload URL in the cache for the specified key.
51+
/// </summary>
52+
/// <param name="key">The unique identifier for the cached URL.</param>
53+
/// <param name="url">The resumable upload URL to cache.</param>
54+
/// <param name="ttl">Optional time-to-live duration. If not specified, uses the default TTL.</param>
55+
/// <exception cref="ArgumentException">Thrown when key or url is null, empty, or whitespace.</exception>
56+
public static void Set(string key, string url, TimeSpan? ttl = null)
57+
{
58+
if (string.IsNullOrWhiteSpace(key))
59+
throw new ArgumentException("Key must be provided.", nameof(key));
60+
if (string.IsNullOrWhiteSpace(url))
61+
throw new ArgumentException("Url must be provided.", nameof(url));
62+
63+
var entryTtl = ttl.GetValueOrDefault(_defaultTtl);
64+
_cache.AddOrUpdate(
65+
key,
66+
_ => new CacheEntry(url, entryTtl),
67+
(_, existing) => new CacheEntry(url, entryTtl)
68+
);
69+
70+
Interlocked.Increment(ref _version);
71+
CleanupIfNeeded();
72+
}
73+
74+
/// <summary>
75+
/// Attempts to retrieve a cached URL by its key. Updates the sliding expiration on successful retrieval.
76+
/// </summary>
77+
/// <param name="key">The unique identifier for the cached URL.</param>
78+
/// <param name="url">When this method returns, contains the cached URL if found; otherwise, null.</param>
79+
/// <returns>True if the URL was found in the cache; otherwise, false.</returns>
80+
public static bool TryGet(string key, out string? url)
81+
{
82+
url = null;
83+
if (string.IsNullOrWhiteSpace(key))
84+
return false;
85+
86+
if (!_cache.TryGetValue(key, out var entry)) return false;
87+
if (entry.IsExpired())
88+
{
89+
_cache.TryRemove(key, out _);
90+
return false;
91+
}
92+
93+
entry.Touch();
94+
url = entry.Url;
95+
return true;
96+
97+
}
98+
99+
/// <summary>
100+
/// Removes a cached URL by its key.
101+
/// </summary>
102+
/// <param name="key">The unique identifier for the cached URL to remove.</param>
103+
/// <returns>True if the URL was successfully removed; otherwise, false.</returns>
104+
public static bool Remove(string key)
105+
{
106+
if (string.IsNullOrWhiteSpace(key))
107+
return false;
108+
109+
var removed = _cache.TryRemove(key, out _);
110+
if (removed)
111+
Interlocked.Increment(ref _version);
112+
return removed;
113+
}
114+
115+
/// <summary>
116+
/// Removes all cached URLs from the cache.
117+
/// </summary>
118+
public static void Clear()
119+
{
120+
_cache.Clear();
121+
Interlocked.Increment(ref _version);
122+
}
123+
124+
/// <summary>
125+
/// Gets the current number of entries in the cache.
126+
/// </summary>
127+
public static int Count => _cache.Count;
128+
129+
private static void CleanupIfNeeded()
130+
{
131+
foreach (var kvp in _cache)
132+
{
133+
if (kvp.Value.IsExpired())
134+
{
135+
_cache.TryRemove(kvp.Key, out _);
136+
}
137+
}
138+
}
139+
}

StorageTests/StorageFileTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ public async Task UploadOrResumeByteWithInterruptionAndResume()
270270

271271
var options = new FileOptions { Duplex = "duplex", Metadata = metadata };
272272

273-
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
273+
using var cts = new CancellationTokenSource();
274274

275275
try
276276
{
@@ -280,6 +280,9 @@ await _bucket.UploadOrResume(
280280
options,
281281
(_, progress) =>
282282
{
283+
if (progress > 20)
284+
cts.Cancel();
285+
283286
Console.WriteLine($"First upload progress: {progress}");
284287
firstUploadProgressTriggered.TrySetResult(true);
285288
},

0 commit comments

Comments
 (0)