Timed tasks

🐛 Bug fixes
This commit is contained in:
2025-04-16 00:18:59 +08:00
parent c901781323
commit 9cffd8383e
7 changed files with 135 additions and 17 deletions

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -7,7 +8,7 @@ namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/accounts")]
public class AccountController(AppDatabase db) : ControllerBase
public class AccountController(AppDatabase db, FileService fs) : ControllerBase
{
[HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
@ -150,14 +151,10 @@ public class AccountController(AppDatabase db) : ControllerBase
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
if (profile.Picture is not null)
{
profile.Picture.UsedCount--;
db.Update(profile.Picture);
}
picture.UsedCount++;
await fs.MarkUsageAsync(profile.Picture, -1);
profile.Picture = picture;
db.Update(picture);
await fs.MarkUsageAsync(picture, 1);
}
if (request.BackgroundId is not null)
@ -165,14 +162,10 @@ public class AccountController(AppDatabase db) : ControllerBase
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
if (profile.Background is not null)
{
profile.Background.UsedCount--;
db.Update(profile.Background);
}
await fs.MarkUsageAsync(profile.Background, -1);
background.UsedCount++;
profile.Background = background;
db.Update(background);
await fs.MarkUsageAsync(background, 1);
}
db.Update(profile);

View File

@ -1,7 +1,9 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using NodaTime;
using Npgsql;
using Quartz;
namespace DysonNetwork.Sphere;
@ -43,7 +45,7 @@ public class AppDatabase(
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Account.Account>()
.HasOne(a => a.Profile)
.WithOne(p => p.Account)
@ -100,6 +102,52 @@ public class AppDatabase(
}
}
public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Deleting soft-deleted records...");
var now = SystemClock.Instance.GetCurrentInstant();
var threshold = now - Duration.FromDays(7);
var entityTypes = db.Model.GetEntityTypes()
.Where(t => typeof(BaseModel).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(BaseModel))
.Select(t => t.ClrType);
foreach (var entityType in entityTypes)
{
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
.MakeGenericMethod(entityType).Invoke(db, null)!;
var parameter = Expression.Parameter(entityType, "e");
var property = Expression.Property(parameter, nameof(BaseModel.DeletedAt));
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
var finalCondition = Expression.AndAlso(notNull, condition);
var lambda = Expression.Lambda(finalCondition, parameter);
var queryable = set.Provider.CreateQuery(
Expression.Call(
typeof(Queryable),
"Where",
[entityType],
set.Expression,
Expression.Quote(lambda)
)
);
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
.MakeGenericMethod(entityType);
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
db.RemoveRange(items);
}
await db.SaveChangesAsync();
}
}
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
{
public AppDatabase CreateDbContext(string[] args)

View File

@ -21,6 +21,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageReference Include="MimeTypes" Version="2.5.2">
<PrivateAssets>all</PrivateAssets>
@ -32,6 +33,9 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.5" />
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.4" />

View File

@ -17,6 +17,7 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using Quartz;
using tusdotnet;
using tusdotnet.Models;
using File = System.IO.File;
@ -117,6 +118,28 @@ builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<FileService>();
// Timed task
builder.Services.AddQuartz(q =>
{
var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling");
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob));
q.AddTrigger(opts => opts
.ForJob(appDatabaseRecyclingJob)
.WithIdentity("AppDatabaseRecyclingTrigger")
.WithCronSchedule("0 0 0 * * ?"));
var cloudFilesRecyclingJob = new JobKey("CloudFilesUnusedRecycling");
q.AddJob<CloudFileUnusedRecyclingJob>(opts => opts.WithIdentity(cloudFilesRecyclingJob));
q.AddTrigger(opts => opts
.ForJob(cloudFilesRecyclingJob)
.WithIdentity("CloudFilesUnusedRecyclingTrigger")
.WithSimpleSchedule(o => o.WithIntervalInHours(1).RepeatForever())
);
});
builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
var app = builder.Build();
using (var scope = app.Services.CreateScope())

View File

@ -93,7 +93,7 @@ public class FileController(
.FirstOrDefaultAsync();
if (file is null) return NotFound();
await fs.DeleteFileDataAsync(file);
await fs.DeleteFileAsync(file);
db.Files.Remove(file);
await db.SaveChangesAsync();

View File

@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore;
using Minio;
using Minio.DataModel.Args;
using NodaTime;
using Quartz;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Metadata.Profiles.Exif;
using ExifTag = SixLabors.ImageSharp.Metadata.Profiles.Exif.ExifTag;
@ -162,6 +163,14 @@ public class FileService(AppDatabase db, IConfiguration configuration)
return file;
}
public async Task DeleteFileAsync(CloudFile file)
{
await DeleteFileDataAsync(file);
db.Remove(file);
await db.SaveChangesAsync();
}
public async Task DeleteFileDataAsync(CloudFile file)
{
if (file.UploadedTo is null) return;
@ -177,7 +186,8 @@ public class FileService(AppDatabase db, IConfiguration configuration)
new RemoveObjectArgs().WithBucket(bucket).WithObject(file.Id)
);
return;
db.Remove(file);
await db.SaveChangesAsync();
}
public RemoteStorageConfig GetRemoteStorageConfig(string destination)
@ -198,4 +208,39 @@ public class FileService(AppDatabase db, IConfiguration configuration)
return client.Build();
}
public async Task MarkUsageAsync(CloudFile file, int delta)
{
await db.Files.Where(o => o.Id == file.Id)
.ExecuteUpdateAsync(
setter => setter.SetProperty(
b => b.UsedCount,
b => b.UsedCount + delta
)
);
}
}
public class CloudFileUnusedRecyclingJob(AppDatabase db, FileService fs, ILogger<CloudFileUnusedRecyclingJob> logger)
: IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Deleting unused cloud files...");
var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1);
var files = db.Files
.Where(f => f.UsedCount == 0)
.Where(f => f.CreatedAt < cutoff)
.ToList();
logger.LogInformation($"Deleting {files.Count} unused cloud files...");
var tasks = files.Select(fs.DeleteFileDataAsync);
await Task.WhenAll(tasks);
await db.Files
.Where(f => f.UsedCount == 0 && f.CreatedAt < cutoff)
.ExecuteDeleteAsync();
}
}