Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Hookline.SharedKernel.Caching;

using Microsoft.Extensions.Logging;

using StackExchange.Redis;

namespace Hookline.Infrastructure.Caching;
Expand All @@ -10,7 +12,7 @@ namespace Hookline.Infrastructure.Caching;
/// runs <c>noeviction</c> but every app key self-expires, so a missed purge only delays cleanup — it must
/// never fail a data reset).
/// </summary>
public sealed class RedisCachePurge(IConnectionMultiplexer redis) : ICachePurge
public sealed class RedisCachePurge(IConnectionMultiplexer redis, ILogger<RedisCachePurge> logger) : ICachePurge
{
public async Task<long> PurgeByPrefixAsync(string prefix, CancellationToken ct = default)
{
Expand Down Expand Up @@ -41,9 +43,10 @@ public async Task<long> PurgeByPrefixAsync(string prefix, CancellationToken ct =
}
}
}
catch (Exception) when (!ct.IsCancellationRequested)
catch (Exception ex) when (!ct.IsCancellationRequested)
{
// Best-effort: a cache outage must never fail the surrounding data reset.
logger.LogWarning(ex, "Cache purge failed for prefix '{Prefix}'", prefix);
}

return removed;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ private static Task Respond(SlackClient slack, string? responseUrl, string text)
private static void MapGoogleEndpoints(IEndpointRouteBuilder app)
{
app.MapGet("/google/youtube-uploads/oauth/start", async (
Guid? projectId, GoogleAccountsService oauth, IOptions<YouTubeUploadsOptions> opt, HttpContext http, CancellationToken ct) =>
Guid? projectId, GoogleAccountsService oauth, IOptions<YouTubeUploadsOptions> opt, HttpContext http, ILoggerFactory loggerFactory, CancellationToken ct) =>
{
var panel = opt.Value.AdminPanelUrl.TrimEnd('/');
if (projectId is null)
Expand All @@ -221,7 +221,11 @@ private static void MapGoogleEndpoints(IEndpointRouteBuilder app)
var state = GenerateState();
string consentUrl;
try { consentUrl = await oauth.BuildConsentUrlAsync(projectId.Value, state, ct); }
catch (Exception ex) { return Results.Redirect($"{panel}/connections/google?error={Uri.EscapeDataString(ex.Message)}"); }
catch (Exception ex)
{
loggerFactory.CreateLogger("YouTubeUploads.Google.OAuth").LogError(ex, "Google OAuth start failed for project {ProjectId}", projectId.Value);
return Results.Redirect($"{panel}/connections/google?error={Uri.EscapeDataString(ex.Message)}");
}

http.Response.Cookies.Append(GoogleStateCookie, state, StateCookie(http));
http.Response.Cookies.Append(GoogleClientCookie, projectId.Value.ToString(), StateCookie(http));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Hookline.SharedKernel.Connections;

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Hookline.Modules.YouTubeUploads.Infrastructure;
Expand Down Expand Up @@ -35,7 +36,8 @@ public sealed class GoogleAccountsService(
YouTubeUploadsDbContext db,
IGoogleConnections googleAccounts,
YouTubeUploadService youtube,
IQuotaService quota)
IQuotaService quota,
ILogger<GoogleAccountsService> logger)
{
private string RedirectUri => options.Value.Google.RedirectUri;

Expand Down Expand Up @@ -73,7 +75,7 @@ public async Task<Guid> ExchangeAndStoreAsync(Guid projectId, string code, Cance
(channelId, channelTitle, avatarUrl) = await youtube.GetChannelInfoAsync(creds.ClientId, creds.ClientSecret, token.RefreshToken, ct);
await quota.ChargeUnitsAsync(projectId, 1); // channels.list ≈ 1 unit against the non-upload pool
}
catch { /* channel lookup is best-effort; the account still works for upload */ }
catch (Exception ex) { logger.LogWarning(ex, "Channel lookup failed during OAuth exchange for project {ProjectId}", projectId); }

var scopes = string.Join(' ', GoogleCredentialFactory.Scopes);
var label = channelTitle ?? "YouTube account";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Hookline.SharedKernel.Connections;

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Hookline.Modules.YouTubeUploads.Infrastructure;

Expand Down Expand Up @@ -71,7 +72,7 @@ public interface IJobService
Task<(int UploadsToday, int UploadsLast24h, int ErrorsLast24h)> GetDashboardCountsAsync(CancellationToken ct = default);
}

public sealed class JobService(YouTubeUploadsDbContext db, IGoogleConnections googleAccounts, IAuditLog audit) : IJobService
public sealed class JobService(YouTubeUploadsDbContext db, IGoogleConnections googleAccounts, IAuditLog audit, ILogger<JobService> logger) : IJobService
{
public async Task<UploadJob> CreateAsync(NewJob i, CancellationToken ct = default)
{
Expand Down Expand Up @@ -147,6 +148,7 @@ await audit.WriteAsync(
catch (Exception ex) when (ex is not OperationCanceledException)
{
// swallow: the transition already committed; losing one audit row is not worth failing the job
logger.LogWarning(ex, "Audit write failed for upload.{State} on job {JobId}", to, job.Id);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

using StackExchange.Redis;
Expand Down Expand Up @@ -39,7 +40,7 @@ public interface IQuotaService
Task ChargeUnitsAsync(Guid projectId, int units);
}

public sealed class QuotaService(IConnectionMultiplexer redis, IOptions<YouTubeUploadsOptions> options) : IQuotaService
public sealed class QuotaService(IConnectionMultiplexer redis, IOptions<YouTubeUploadsOptions> options, ILogger<QuotaService> logger) : IQuotaService
{
private readonly YouTubeUploadsOptions _opt = options.Value;

Expand Down Expand Up @@ -91,6 +92,6 @@ public async Task ChargeUnitsAsync(Guid projectId, int units)
if (after == units) // first charge of this PT day -> bound the key's lifetime
await db.KeyExpireAsync(key, PacificTime.UntilMidnight() + TimeSpan.FromHours(1));
}
catch { /* best-effort meter — a Redis hiccup must never break the calling request */ }
catch (Exception ex) { logger.LogWarning(ex, "Quota unit charge failed for project {ProjectId}", projectId); }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ await drive.DownloadAsync(driveService, job.DriveFileId, fileOut, bytes =>
// doesn't strand the project's daily upload bucket on an upload that produced nothing.
if (reservedProjectId is not null && job.YouTubeVideoId is null)
{
try { await quota.ReleaseUploadAsync(reservedProjectId.Value); } catch { /* best effort */ }
try { await quota.ReleaseUploadAsync(reservedProjectId.Value); }
catch (Exception qex) { logger.LogDebug(qex, "Best-effort quota release failed for project {ProjectId}", reservedProjectId.Value); }
}
// Before the YouTube upload starts nothing exists on YouTube → re-queue so startup
// recovery resumes it from scratch. Once Uploading/Processing it is the point of no
Expand All @@ -236,12 +237,14 @@ await drive.DownloadAsync(driveService, job.DriveFileId, fileOut, bytes =>
// failed attempt doesn't burn the project's daily upload bucket (the counter is an estimate).
if (reservedProjectId is not null && job.YouTubeVideoId is null)
{
try { await quota.ReleaseUploadAsync(reservedProjectId.Value); } catch { /* best effort */ }
try { await quota.ReleaseUploadAsync(reservedProjectId.Value); }
catch (Exception qex) { logger.LogDebug(qex, "Best-effort quota release failed for project {ProjectId}", reservedProjectId.Value); }
}
// Revoked / wrong-client / unauthorized → flag the account so rotation skips it next time.
if (activeAccountId is not null && job.YouTubeVideoId is null && IsAuthError(ex))
{
try { await oauth.MarkAccountErrorAsync(activeAccountId.Value); } catch { /* best effort */ }
try { await oauth.MarkAccountErrorAsync(activeAccountId.Value); }
catch (Exception aex) { logger.LogWarning(aex, "Best-effort MarkAccountError failed for account {AccountId}", activeAccountId.Value); }
}
await FailAsync(job, Summarize(ex));
await status.RefreshQueueAsync(CancellationToken.None);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Hookline.SharedKernel.Secrets;

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;

namespace Hookline.Modules.YouTubeUploads.Tests;

Expand Down Expand Up @@ -62,7 +63,7 @@ public async Task Cancel_while_queued_transition_emits_an_audit_entry_and_keeps_
using var db = NewDb();
var audit = new RecordingAuditLog();
// googleAccounts is unused by TransitionAsync — only the db + audit collaborators matter here.
var jobs = new JobService(db, googleAccounts: null!, audit);
var jobs = new JobService(db, googleAccounts: null!, audit, NullLogger<JobService>.Instance);

var job = NewQueuedJob();
db.Jobs.Add(job);
Expand Down Expand Up @@ -91,7 +92,7 @@ public async Task Transition_action_name_tracks_the_target_state(JobState to, st
{
using var db = NewDb();
var audit = new RecordingAuditLog();
var jobs = new JobService(db, googleAccounts: null!, audit);
var jobs = new JobService(db, googleAccounts: null!, audit, NullLogger<JobService>.Instance);

var job = NewQueuedJob();
db.Jobs.Add(job);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Hookline.Modules.YouTubeUploads.Infrastructure;
using Hookline.Modules.YouTubeUploads.Jobs;

using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace Hookline.Modules.YouTubeUploads.Tests;
Expand Down Expand Up @@ -55,7 +56,7 @@ public void UnitMeterIsIndependentOfUploads()
public async Task GetStatusNullProjectReportsZeroCap()
{
// An unbound account (null project) must not look like it has a full daily quota available.
var quota = new QuotaService(null!, Options.Create(new YouTubeUploadsOptions()));
var quota = new QuotaService(null!, Options.Create(new YouTubeUploadsOptions()), NullLogger<QuotaService>.Instance);
var status = await quota.GetStatusAsync(null);
Assert.Equal(0, status.UploadLimit);
Assert.Equal(0, status.CapUnits);
Expand Down
Loading