diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7cef88e..daa4de1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -27,6 +27,11 @@
+
+
+
+
+
diff --git a/Tiku.Api/Caching/TenantPublicCacheInvalidator.cs b/Tiku.Api/Caching/TenantPublicCacheInvalidator.cs
new file mode 100644
index 0000000..7d9d8f5
--- /dev/null
+++ b/Tiku.Api/Caching/TenantPublicCacheInvalidator.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.OutputCaching;
+using Tiku.Application.Tenancy;
+
+namespace Tiku.Api.Caching;
+
+internal sealed class TenantPublicCacheInvalidator(IOutputCacheStore outputCacheStore)
+ : ITenantPublicCacheInvalidator
+{
+ public ValueTask InvalidateCoreAsync(Guid tenantId, CancellationToken cancellationToken) =>
+ outputCacheStore.EvictByTagAsync($"tenant:{tenantId:N}", cancellationToken);
+
+ public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
+ await InvalidateCoreAsync(tenantId, cancellationToken);
+}
diff --git a/Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs b/Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs
new file mode 100644
index 0000000..9ff7feb
--- /dev/null
+++ b/Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs
@@ -0,0 +1,48 @@
+using Microsoft.AspNetCore.OutputCaching;
+using Microsoft.Extensions.Primitives;
+using Tiku.Application.Security;
+
+namespace Tiku.Api.Caching;
+
+internal sealed class TenantPublicOutputCachePolicy : IOutputCachePolicy
+{
+ public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
+ {
+ var request = context.HttpContext.Request;
+ var tenantId = context.HttpContext.RequestServices.GetRequiredService().TenantId;
+ var enabled = HttpMethods.IsGet(request.Method) &&
+ tenantId.HasValue &&
+ StringValues.IsNullOrEmpty(request.Headers.Authorization) &&
+ context.HttpContext.User.Identity?.IsAuthenticated != true;
+ context.EnableOutputCaching = enabled;
+ context.AllowCacheLookup = enabled;
+ context.AllowCacheStorage = enabled;
+ context.AllowLocking = true;
+ if (enabled)
+ {
+ var resolvedTenantId = tenantId.GetValueOrDefault();
+ context.CacheVaryByRules.QueryKeys = "*";
+ context.CacheVaryByRules.HeaderNames = new StringValues("Accept-Encoding");
+ context.CacheVaryByRules.VaryByValues["tenant"] = resolvedTenantId.ToString("N");
+ context.Tags.Add($"tenant:{resolvedTenantId:N}");
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) =>
+ ValueTask.CompletedTask;
+
+ public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
+ {
+ var response = context.HttpContext.Response;
+ if (response.StatusCode != StatusCodes.Status200OK ||
+ !StringValues.IsNullOrEmpty(response.Headers.SetCookie) ||
+ context.HttpContext.User.Identity?.IsAuthenticated == true)
+ {
+ context.AllowCacheStorage = false;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs b/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs
index 5a3ec18..533216d 100644
--- a/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs
+++ b/Tiku.Api/Configuration/ApplicationBuilderExtensions.cs
@@ -22,9 +22,11 @@ public static class ApplicationBuilderExtensions
}
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
+ app.UseMiddleware();
app.UseMiddleware();
app.UseForwardedHeaders();
app.UseHttpsRedirection();
+ app.UseResponseCompression();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseRouting();
@@ -37,6 +39,7 @@ public static class ApplicationBuilderExtensions
app.UseMiddleware();
app.UseAuthorization();
app.UseMiddleware();
+ app.UseOutputCache();
app.MapControllers();
return app;
diff --git a/Tiku.Api/Configuration/AuthenticationExtensions.cs b/Tiku.Api/Configuration/AuthenticationExtensions.cs
index 3dbed39..1ce0bc1 100644
--- a/Tiku.Api/Configuration/AuthenticationExtensions.cs
+++ b/Tiku.Api/Configuration/AuthenticationExtensions.cs
@@ -209,7 +209,11 @@ internal static class AuthenticationExtensions
if (session is null)
{
context.Fail("Session, identity, membership, tenant or role state is no longer valid.");
+ return;
}
+
+ context.HttpContext.RequestServices.GetRequiredService()
+ .SetValidatedSession(session);
}
private static async Task WriteTenantConflictChallengeAsync(JwtBearerChallengeContext context)
diff --git a/Tiku.Api/Configuration/DependencyInjection.cs b/Tiku.Api/Configuration/DependencyInjection.cs
index 58d7aed..4eba680 100644
--- a/Tiku.Api/Configuration/DependencyInjection.cs
+++ b/Tiku.Api/Configuration/DependencyInjection.cs
@@ -3,6 +3,12 @@ using Tiku.Application;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Security;
+using Tiku.Api.Caching;
+using Microsoft.AspNetCore.ResponseCompression;
+using System.IO.Compression;
+using Tiku.Api.Observability;
+using Tiku.Application.Tenancy;
+using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Tiku.Api.Configuration;
@@ -18,6 +24,9 @@ public static class DependencyInjection
builder.Services.AddApiPresentation();
builder.Services.AddHealthChecks();
+ builder.Services.AddApiObservability(builder.Configuration, builder.Environment);
+ builder.Services.AddSingleton();
+ builder.Services.AddHostedService();
builder.Services.AddApplication();
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
builder.Services.AddApiRateLimiting(builder.Configuration);
@@ -42,6 +51,31 @@ public static class DependencyInjection
throw new InvalidOperationException(
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
}
+
+ builder.Services.AddOutputCache(options =>
+ {
+ options.AddPolicy("TenantPublic", new TenantPublicOutputCachePolicy());
+ options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(60);
+ });
+ builder.Services.RemoveAll();
+ builder.Services.AddSingleton();
+ if (builder.Environment.IsProduction() && !string.IsNullOrWhiteSpace(redisConnectionString))
+ {
+ builder.Services.AddStackExchangeRedisOutputCache(options =>
+ {
+ options.Configuration = redisConnectionString;
+ options.InstanceName = $"tiku:{builder.Environment.EnvironmentName.ToLowerInvariant()}:output:";
+ });
+ }
+ builder.Services.AddResponseCompression(options =>
+ {
+ options.EnableForHttps = true;
+ options.Providers.Add();
+ options.Providers.Add();
+ options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
+ });
+ builder.Services.Configure(options => options.Level = CompressionLevel.Fastest);
+ builder.Services.Configure(options => options.Level = CompressionLevel.Fastest);
var messaging = builder.Configuration.GetSection("RabbitMq").Get() ?? new MessagingOptions();
builder.Services.AddOptions()
.Bind(builder.Configuration.GetSection("RabbitMq"))
diff --git a/Tiku.Api/Configuration/ObservabilityExtensions.cs b/Tiku.Api/Configuration/ObservabilityExtensions.cs
new file mode 100644
index 0000000..69d4b27
--- /dev/null
+++ b/Tiku.Api/Configuration/ObservabilityExtensions.cs
@@ -0,0 +1,46 @@
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using Tiku.Infrastructure.Observability;
+
+namespace Tiku.Api.Configuration;
+
+internal static class ObservabilityExtensions
+{
+ internal static IServiceCollection AddApiObservability(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ IHostEnvironment environment)
+ {
+ var endpoint = configuration["OpenTelemetry:OtlpEndpoint"];
+ var hasOtlpEndpoint = Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri);
+
+ services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource.AddService(
+ serviceName: environment.ApplicationName,
+ serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString()))
+ .WithTracing(tracing => tracing
+ .AddAspNetCoreInstrumentation(options =>
+ options.Filter = context => !context.Request.Path.StartsWithSegments("/api/health"))
+ .AddHttpClientInstrumentation()
+ .AddSource("Npgsql")
+ .ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)))
+ .WithMetrics(metrics => metrics
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Tiku.Messaging", "Npgsql")
+ .ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
+
+ return services;
+ }
+
+ private static TBuilder ApplyIf(this TBuilder builder, bool condition, Action configure)
+ {
+ if (condition)
+ {
+ configure(builder);
+ }
+
+ return builder;
+ }
+}
diff --git a/Tiku.Api/Contracts/ScorelineDtos.cs b/Tiku.Api/Contracts/ScorelineDtos.cs
index e3a35a3..195b1ab 100644
--- a/Tiku.Api/Contracts/ScorelineDtos.cs
+++ b/Tiku.Api/Contracts/ScorelineDtos.cs
@@ -57,6 +57,12 @@ public sealed class ScorelineQueryDto
[Range(1, 2000)]
public int? Limit { get; set; }
+ ///
+ /// 版本化的不透明游标,仅用于游标分页接口。
+ ///
+ [StringLength(2000)]
+ public string? Cursor { get; set; }
+
public ScorelineFilter ToFilter(
Guid tenantId,
IReadOnlyCollection? dynamicFilters = null)
diff --git a/Tiku.Api/Controllers/CatalogController.cs b/Tiku.Api/Controllers/CatalogController.cs
index 1dff021..3f593d4 100644
--- a/Tiku.Api/Controllers/CatalogController.cs
+++ b/Tiku.Api/Controllers/CatalogController.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.OutputCaching;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Api.Contracts;
@@ -18,6 +19,7 @@ namespace Tiku.Api.Controllers;
[AllowAnonymous]
[Produces("application/json")]
[Route("api/catalog")]
+[OutputCache(PolicyName = "TenantPublic")]
public sealed class CatalogController(
ICatalogQueryService catalogQueryService,
IContentNavigationQueryService contentNavigationQueryService,
diff --git a/Tiku.Api/Controllers/HealthController.cs b/Tiku.Api/Controllers/HealthController.cs
index e49aba5..e9f9ebf 100644
--- a/Tiku.Api/Controllers/HealthController.cs
+++ b/Tiku.Api/Controllers/HealthController.cs
@@ -4,9 +4,9 @@ using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
-using MassTransit.EntityFrameworkCoreIntegration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Tiku.Infrastructure.Messaging;
+using Tiku.Api.Observability;
namespace Tiku.Api.Controllers;
@@ -19,7 +19,8 @@ public sealed class HealthController(
TikuDbContext dbContext,
IRedisSecurityStore redisSecurityStore,
MessagingOptions messagingOptions,
- HealthCheckService healthCheckService) : ControllerBase
+ HealthCheckService healthCheckService,
+ OutboxBacklogSnapshot outboxSnapshot) : ControllerBase
{
[HttpGet]
[EndpointSummary("健康检查")]
@@ -43,17 +44,8 @@ public sealed class HealthController(
registration => registration.Tags.Contains("ready"),
cancellationToken);
var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy;
- var outboxPending = database
- ? await dbContext.Set().CountAsync(cancellationToken)
- : -1;
- var outboxOldestSentTime = database
- ? await dbContext.Set()
- .Select(message => (DateTime?)message.SentTime)
- .MinAsync(cancellationToken)
- : null;
- var outboxOldestAgeSeconds = outboxOldestSentTime is null
- ? 0
- : Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(outboxOldestSentTime.Value)).TotalSeconds);
+ var outboxPending = outboxSnapshot.Pending;
+ var outboxOldestAgeSeconds = outboxSnapshot.OldestAgeSeconds;
var outboxAlert = outboxPending >= messagingOptions.OutboxBacklogAlertCount ||
outboxOldestAgeSeconds >= messagingOptions.OutboxOldestMessageAlertSeconds;
var ready = database && redis && rabbitMq;
diff --git a/Tiku.Api/Controllers/RuntimeController.cs b/Tiku.Api/Controllers/RuntimeController.cs
index cfe6db2..7004a67 100644
--- a/Tiku.Api/Controllers/RuntimeController.cs
+++ b/Tiku.Api/Controllers/RuntimeController.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.OutputCaching;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
@@ -15,6 +16,7 @@ public sealed class RuntimeController(
ITenantFrontendConfigService frontendConfigService) : ControllerBase
{
[HttpGet("bootstrap")]
+ [OutputCache(PolicyName = "TenantPublic")]
[EndpointSummary("获取租户前端运行时配置")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status304NotModified)]
diff --git a/Tiku.Api/Controllers/ScorelineController.cs b/Tiku.Api/Controllers/ScorelineController.cs
index e0a4e12..3c001eb 100644
--- a/Tiku.Api/Controllers/ScorelineController.cs
+++ b/Tiku.Api/Controllers/ScorelineController.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.OutputCaching;
using Microsoft.EntityFrameworkCore;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
@@ -24,6 +25,7 @@ public sealed class ScorelineController(
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];
[HttpGet("fields")]
+ [OutputCache(PolicyName = "TenantPublic")]
[EndpointSummary("查询分数线字段配置")]
[ProducesResponseType>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
@@ -49,6 +51,20 @@ public sealed class ScorelineController(
cancellationToken));
}
+ [HttpGet("records/cursor")]
+ [EndpointSummary("游标分页查询分数线记录")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task> GetRecordsCursor(
+ [FromQuery] ScorelineQueryDto query,
+ CancellationToken cancellationToken)
+ {
+ return Ok(await scorelineQueryService.GetRecordsCursorAsync(
+ query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), ResolveDynamicFilters()),
+ query.Cursor,
+ cancellationToken));
+ }
+
[HttpGet("trend")]
[EndpointSummary("查询历年分数线趋势")]
[ProducesResponseType>(StatusCodes.Status200OK)]
@@ -63,6 +79,7 @@ public sealed class ScorelineController(
}
[HttpGet("years")]
+ [OutputCache(PolicyName = "TenantPublic")]
[EndpointSummary("查询分数线可用年份")]
[ProducesResponseType>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
diff --git a/Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs b/Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs
new file mode 100644
index 0000000..41b18f7
--- /dev/null
+++ b/Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs
@@ -0,0 +1,12 @@
+using Tiku.Infrastructure.Observability;
+
+namespace Tiku.Api.Middleware;
+
+public sealed class DatabaseRequestMetricsMiddleware(RequestDelegate next)
+{
+ public async Task InvokeAsync(HttpContext context)
+ {
+ using var metrics = DatabaseRequestMetrics.Begin();
+ await next(context);
+ }
+}
diff --git a/Tiku.Api/Observability/OutboxBacklogMonitor.cs b/Tiku.Api/Observability/OutboxBacklogMonitor.cs
new file mode 100644
index 0000000..33a30b4
--- /dev/null
+++ b/Tiku.Api/Observability/OutboxBacklogMonitor.cs
@@ -0,0 +1,66 @@
+using System.Diagnostics.Metrics;
+using Npgsql;
+using Tiku.Infrastructure.Messaging;
+
+namespace Tiku.Api.Observability;
+
+public sealed class OutboxBacklogSnapshot
+{
+ private static readonly Meter Meter = new("Tiku.Messaging", "1.0.0");
+ private long pending;
+ private double oldestAgeSeconds;
+
+ public OutboxBacklogSnapshot()
+ {
+ Meter.CreateObservableGauge("tiku.outbox.pending", () => Interlocked.Read(ref pending));
+ Meter.CreateObservableGauge("tiku.outbox.oldest_age", () => Volatile.Read(ref oldestAgeSeconds), "s");
+ }
+
+ internal long Pending => Interlocked.Read(ref pending);
+ internal double OldestAgeSeconds => Volatile.Read(ref oldestAgeSeconds);
+
+ internal void Update(long count, DateTime? oldestSentTime)
+ {
+ Interlocked.Exchange(ref pending, count);
+ Volatile.Write(ref oldestAgeSeconds, oldestSentTime is null
+ ? 0
+ : Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(oldestSentTime.Value)).TotalSeconds));
+ }
+}
+
+internal sealed class OutboxBacklogMonitor(
+ NpgsqlDataSource dataSource,
+ OutboxBacklogSnapshot snapshot,
+ MessagingOptions messagingOptions,
+ ILogger logger) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ if (messagingOptions.IsConfigured)
+ {
+ await using var command = dataSource.CreateCommand(
+ "SELECT count(*), min(sent_time) FROM outbox_message;");
+ await using var reader = await command.ExecuteReaderAsync(stoppingToken);
+ if (await reader.ReadAsync(stoppingToken))
+ {
+ snapshot.Update(reader.GetInt64(0), reader.IsDBNull(1) ? null : reader.GetDateTime(1));
+ }
+ }
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception exception)
+ {
+ logger.LogWarning(exception, "Outbox backlog metric collection failed.");
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
+ }
+ }
+}
diff --git a/Tiku.Api/Tiku.Api.csproj b/Tiku.Api/Tiku.Api.csproj
index e36de1c..7596c43 100644
--- a/Tiku.Api/Tiku.Api.csproj
+++ b/Tiku.Api/Tiku.Api.csproj
@@ -16,7 +16,12 @@
+
+
+
+
+
diff --git a/Tiku.Api/appsettings.json b/Tiku.Api/appsettings.json
index f57141a..233fe1e 100644
--- a/Tiku.Api/appsettings.json
+++ b/Tiku.Api/appsettings.json
@@ -26,6 +26,9 @@
"Application": "Tiku.Api"
}
},
+ "OpenTelemetry": {
+ "OtlpEndpoint": ""
+ },
"Cors": {
"AllowedOrigins": [],
"AllowedHeaders": [
diff --git a/Tiku.Application/Auth/IRequestSecurityState.cs b/Tiku.Application/Auth/IRequestSecurityState.cs
new file mode 100644
index 0000000..0aacdd7
--- /dev/null
+++ b/Tiku.Application/Auth/IRequestSecurityState.cs
@@ -0,0 +1,24 @@
+namespace Tiku.Application.Auth;
+
+public interface IRequestSecurityState
+{
+ AuthSessionValidationResult? ValidatedSession { get; }
+
+ void SetValidatedSession(AuthSessionValidationResult session);
+}
+
+internal sealed class RequestSecurityState : IRequestSecurityState
+{
+ public AuthSessionValidationResult? ValidatedSession { get; private set; }
+
+ public void SetValidatedSession(AuthSessionValidationResult session)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ if (ValidatedSession is not null && ValidatedSession != session)
+ {
+ throw new InvalidOperationException("The request security session was already initialized.");
+ }
+
+ ValidatedSession = session;
+ }
+}
diff --git a/Tiku.Application/DependencyInjection.cs b/Tiku.Application/DependencyInjection.cs
index ca867be..268acdf 100644
--- a/Tiku.Application/DependencyInjection.cs
+++ b/Tiku.Application/DependencyInjection.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
+using Tiku.Application.Auth;
namespace Tiku.Application;
@@ -8,6 +9,7 @@ public static class DependencyInjection
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped(provider => provider.GetRequiredService());
services.AddScoped(provider => provider.GetRequiredService());
diff --git a/Tiku.Application/Scoreline/ScorelineModels.cs b/Tiku.Application/Scoreline/ScorelineModels.cs
index a5de053..5e0dd94 100644
--- a/Tiku.Application/Scoreline/ScorelineModels.cs
+++ b/Tiku.Application/Scoreline/ScorelineModels.cs
@@ -51,10 +51,17 @@ public sealed record ScorelineRecordPage(
int Total,
IReadOnlyCollection Items);
+public sealed record ScorelineRecordCursorPage(
+ int PageSize,
+ bool HasMore,
+ string? NextCursor,
+ IReadOnlyCollection Items);
+
public interface IScorelineQueryService
{
Task> GetFieldsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default);
Task GetRecordsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default);
+ Task GetRecordsCursorAsync(ScorelineFilter filter, string? cursor, CancellationToken cancellationToken = default);
Task> GetTrendAsync(ScorelineFilter filter, CancellationToken cancellationToken = default);
Task> GetYearsAsync(ScorelineFilter filter, CancellationToken cancellationToken = default);
}
diff --git a/Tiku.Application/Security/ITenantFeatureCacheInvalidator.cs b/Tiku.Application/Security/ITenantFeatureCacheInvalidator.cs
new file mode 100644
index 0000000..9efc2fb
--- /dev/null
+++ b/Tiku.Application/Security/ITenantFeatureCacheInvalidator.cs
@@ -0,0 +1,6 @@
+namespace Tiku.Application.Security;
+
+public interface ITenantFeatureCacheInvalidator
+{
+ Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default);
+}
diff --git a/Tiku.Application/Tenancy/ITenantPublicCacheInvalidator.cs b/Tiku.Application/Tenancy/ITenantPublicCacheInvalidator.cs
new file mode 100644
index 0000000..f992aaa
--- /dev/null
+++ b/Tiku.Application/Tenancy/ITenantPublicCacheInvalidator.cs
@@ -0,0 +1,6 @@
+namespace Tiku.Application.Tenancy;
+
+public interface ITenantPublicCacheInvalidator
+{
+ Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default);
+}
diff --git a/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs b/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs
index 6b49f79..7ac93d1 100644
--- a/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs
+++ b/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs
@@ -35,5 +35,5 @@ public interface ITenantDomainLifecycleService
public interface ITenantRuntimeCacheInvalidator
{
- void Invalidate(Guid tenantId);
+ Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default);
}
diff --git a/Tiku.Infrastructure/Auth/AuthSessionStore.cs b/Tiku.Infrastructure/Auth/AuthSessionStore.cs
index a89895f..fde2f65 100644
--- a/Tiku.Infrastructure/Auth/AuthSessionStore.cs
+++ b/Tiku.Infrastructure/Auth/AuthSessionStore.cs
@@ -152,28 +152,43 @@ public sealed class AuthSessionStore(
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
- var session = await dbContext.AuthSessions.AsNoTracking().SingleOrDefaultAsync(
- item => item.Id == sessionId && item.UserId == userId && item.Realm == realm &&
- item.TenantId == tenantId && item.RevokedAt == null && item.ExpiresAt > now,
- cancellationToken);
- if (session is null)
- {
- return null;
- }
-
- var user = await dbContext.Users.AsNoTracking().SingleOrDefaultAsync(item => item.Id == userId, cancellationToken);
- if (user is null || user.Status != UserStatus.Active ||
- !string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal))
- {
- return null;
- }
-
- try
- {
- await AssertRealmAccessAsync(
- realm, tenantId, userId, cancellationToken);
- }
- catch (TenantAccessDeniedException)
+ var state = await (
+ from session in dbContext.AuthSessions.AsNoTracking()
+ join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
+ where session.Id == sessionId &&
+ session.UserId == userId &&
+ session.Realm == realm &&
+ session.TenantId == tenantId &&
+ session.RevokedAt == null &&
+ session.ExpiresAt > now
+ select new
+ {
+ UserStatus = user.Status,
+ UserSecurityStamp = user.SecurityStamp,
+ SessionSecurityStamp = session.SecurityStamp,
+ TenantAllowed = realm != AuthRealm.Tenant ||
+ (tenantId != null &&
+ dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) &&
+ dbContext.TenantMemberships.Any(item =>
+ item.TenantId == tenantId &&
+ item.UserId == userId &&
+ item.Status == MembershipStatus.Active)),
+ PlatformAllowed = realm != AuthRealm.Platform ||
+ (from userRole in dbContext.PlatformBackendUserRoles
+ join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
+ join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
+ join permission in dbContext.BackendPermissions on binding.PermissionCode equals permission.Code
+ where userRole.UserId == userId &&
+ role.Status == BackendRoleStatus.Active &&
+ (permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
+ select permission.Id).Any()
+ })
+ .SingleOrDefaultAsync(cancellationToken);
+ if (state is null ||
+ state.UserStatus != UserStatus.Active ||
+ !string.Equals(state.UserSecurityStamp, state.SessionSecurityStamp, StringComparison.Ordinal) ||
+ !state.TenantAllowed ||
+ !state.PlatformAllowed)
{
return null;
}
diff --git a/Tiku.Infrastructure/DependencyInjection.cs b/Tiku.Infrastructure/DependencyInjection.cs
index c30ab1f..41ac24a 100644
--- a/Tiku.Infrastructure/DependencyInjection.cs
+++ b/Tiku.Infrastructure/DependencyInjection.cs
@@ -49,6 +49,7 @@ using Tiku.Domain.Identity;
using StackExchange.Redis;
using MassTransit;
using Tiku.Infrastructure.Messaging;
+using Tiku.Infrastructure.Observability;
namespace Tiku.Infrastructure;
@@ -63,13 +64,16 @@ public static class DependencyInjection
services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString));
services.AddScoped();
+ services.AddSingleton();
services.AddDbContext((serviceProvider, options) =>
{
var dataSource = serviceProvider.GetRequiredService();
options.UseNpgsql(dataSource, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
configureDatabase?.Invoke(options);
- options.AddInterceptors(serviceProvider.GetRequiredService());
+ options.AddInterceptors(
+ serviceProvider.GetRequiredService(),
+ serviceProvider.GetRequiredService());
});
services.AddIdentityCore(options =>
{
@@ -94,6 +98,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddSingleton();
+ services.AddSingleton();
services.AddHttpClient();
services.AddHttpClient();
services.AddScoped();
@@ -117,6 +122,7 @@ public static class DependencyInjection
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
services.AddScoped();
@@ -141,6 +147,10 @@ public static class DependencyInjection
services.AddOptions();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddSingleton();
+ services.AddSingleton(provider => provider.GetRequiredService());
+ services.AddHostedService(provider => provider.GetRequiredService());
services.AddScoped();
services.AddScoped();
services.AddOptions();
diff --git a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
index 1cc1490..0adae11 100644
--- a/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
+++ b/Tiku.Infrastructure/Jobs/BackgroundJobService.cs
@@ -97,20 +97,38 @@ internal sealed class BackgroundJobService(
CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
- var jobs = await dbContext.BackgroundJobs
- .Where(job =>
- job.Status == BackgroundJobStatus.Pending &&
- (includeImmediateJobs || job.RunAfter != null) &&
- (job.RunAfter == null || job.RunAfter <= now))
- .OrderBy(job => job.CreatedAt)
- .Take(Math.Clamp(batchSize, 1, 100))
+ var leaseExpiresAt = now.Add(LeaseDuration);
+ var claimedIds = await dbContext.Database.SqlQuery($"""
+ UPDATE background_jobs AS job
+ SET status = 'processing',
+ locked_by = {workerId},
+ lock_expires_at = {leaseExpiresAt},
+ started_at = COALESCE(started_at, {now}),
+ updated_at = {now}
+ WHERE job.id IN (
+ SELECT candidate.id
+ FROM background_jobs AS candidate
+ WHERE (
+ (candidate.status = 'pending' AND ({includeImmediateJobs} OR candidate.run_after IS NOT NULL) AND
+ (candidate.run_after IS NULL OR candidate.run_after <= {now})) OR
+ (candidate.status = 'processing' AND candidate.lock_expires_at <= {now})
+ )
+ ORDER BY candidate.created_at, candidate.id
+ FOR UPDATE SKIP LOCKED
+ LIMIT {Math.Clamp(batchSize, 1, 100)}
+ )
+ RETURNING job.id AS "Value"
+ """)
.ToArrayAsync(cancellationToken);
var processed = 0;
- foreach (var job in jobs)
+ dbContext.ChangeTracker.Clear();
+ foreach (var jobId in claimedIds)
{
cancellationToken.ThrowIfCancellationRequested();
- if (await ProcessJobAsync(job, workerId, cancellationToken)) processed++;
+ var job = await dbContext.BackgroundJobs.SingleAsync(value => value.Id == jobId, cancellationToken);
+ if (await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken)) processed++;
+ dbContext.ChangeTracker.Clear();
}
return processed;
@@ -124,6 +142,21 @@ internal sealed class BackgroundJobService(
CancellationToken cancellationToken = default)
{
var normalizedJobType = NormalizeJobType(jobType);
+ var claimed = await dbContext.BackgroundJobs
+ .Where(item => item.Id == jobId && item.TenantId == tenantId &&
+ item.JobType == normalizedJobType && item.Status == BackgroundJobStatus.Pending &&
+ item.RunAfter == null)
+ .ExecuteUpdateAsync(setters => setters
+ .SetProperty(item => item.Status, BackgroundJobStatus.Processing)
+ .SetProperty(item => item.LockedBy, workerId)
+ .SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.Add(LeaseDuration))
+ .SetProperty(item => item.StartedAt, DateTimeOffset.UtcNow), cancellationToken);
+ if (claimed == 0)
+ {
+ return false;
+ }
+
+ dbContext.ChangeTracker.Clear();
var job = await dbContext.BackgroundJobs.SingleOrDefaultAsync(
item => item.Id == jobId && item.TenantId == tenantId,
cancellationToken);
@@ -135,20 +168,17 @@ internal sealed class BackgroundJobService(
{
throw new InvalidOperationException("The requested background job type does not match the persisted job.");
}
- if (job.Status != BackgroundJobStatus.Pending || job.RunAfter is not null)
- {
- return false;
- }
-
- return await ProcessJobAsync(job, workerId, cancellationToken);
+ return await ProcessJobAsync(job, workerId, alreadyClaimed: true, cancellationToken);
}
private async Task ProcessJobAsync(
BackgroundJob job,
string workerId,
+ bool alreadyClaimed,
CancellationToken cancellationToken)
{
- if (job.Status != BackgroundJobStatus.Pending)
+ if ((!alreadyClaimed && job.Status != BackgroundJobStatus.Pending) ||
+ (alreadyClaimed && (job.Status != BackgroundJobStatus.Processing || job.LockedBy != workerId)))
{
return false;
}
@@ -158,19 +188,25 @@ internal sealed class BackgroundJobService(
FeatureAccessOperation.Write,
cancellationToken)).Allowed)
{
- job.Status = BackgroundJobStatus.Failed;
- job.CompletedAt = DateTimeOffset.UtcNow;
- job.LastError = "Tenant feature entitlement was revoked before job execution.";
- await dbContext.SaveChangesAsync(cancellationToken);
+ await CompleteAsync(
+ job,
+ workerId,
+ BackgroundJobStatus.Failed,
+ JsonDefaults.Object(),
+ "Tenant feature entitlement was revoked before job execution.",
+ cancellationToken);
return true;
}
- var now = DateTimeOffset.UtcNow;
- job.Status = BackgroundJobStatus.Processing;
- job.LockedBy = workerId;
- job.LockExpiresAt = now.Add(LeaseDuration);
- job.StartedAt = now;
- await dbContext.SaveChangesAsync(cancellationToken);
+ if (!alreadyClaimed)
+ {
+ var now = DateTimeOffset.UtcNow;
+ job.Status = BackgroundJobStatus.Processing;
+ job.LockedBy = workerId;
+ job.LockExpiresAt = now.Add(LeaseDuration);
+ job.StartedAt = now;
+ await dbContext.SaveChangesAsync(cancellationToken);
+ }
try
{
@@ -198,14 +234,35 @@ internal sealed class BackgroundJobService(
}
finally
{
- job.LockedBy = null;
- job.LockExpiresAt = null;
- await dbContext.SaveChangesAsync(cancellationToken);
+ await CompleteAsync(job, workerId, job.Status, job.Result, job.LastError, cancellationToken);
}
return true;
}
+ private async Task CompleteAsync(
+ BackgroundJob job,
+ string workerId,
+ BackgroundJobStatus status,
+ JsonElement result,
+ string? lastError,
+ CancellationToken cancellationToken)
+ {
+ await dbContext.BackgroundJobs
+ .Where(value => value.Id == job.Id && value.LockedBy == workerId)
+ .ExecuteUpdateAsync(setters => setters
+ .SetProperty(value => value.Status, status)
+ .SetProperty(value => value.RetryCount, job.RetryCount)
+ .SetProperty(value => value.RunAfter, job.RunAfter)
+ .SetProperty(value => value.CompletedAt, job.CompletedAt)
+ .SetProperty(value => value.LastError, lastError)
+ .SetProperty(value => value.OutputAssetId, job.OutputAssetId)
+ .SetProperty(value => value.Result, result)
+ .SetProperty(value => value.LockedBy, (string?)null)
+ .SetProperty(value => value.LockExpiresAt, (DateTimeOffset?)null),
+ cancellationToken);
+ }
+
public async Task> ListAsync(
Guid tenantId,
string? jobType = null,
diff --git a/Tiku.Infrastructure/Observability/DatabasePerformanceInterceptor.cs b/Tiku.Infrastructure/Observability/DatabasePerformanceInterceptor.cs
new file mode 100644
index 0000000..0ae5a68
--- /dev/null
+++ b/Tiku.Infrastructure/Observability/DatabasePerformanceInterceptor.cs
@@ -0,0 +1,83 @@
+using System.Data.Common;
+using System.Diagnostics;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+namespace Tiku.Infrastructure.Observability;
+
+public sealed class DatabasePerformanceInterceptor(
+ ILogger logger) : DbCommandInterceptor
+{
+ private const double SlowCommandMilliseconds = 500;
+
+ public override DbDataReader ReaderExecuted(
+ DbCommand command,
+ CommandExecutedEventData eventData,
+ DbDataReader result)
+ {
+ Record(command, eventData.Duration, "reader");
+ return result;
+ }
+
+ public override ValueTask ReaderExecutedAsync(
+ DbCommand command,
+ CommandExecutedEventData eventData,
+ DbDataReader result,
+ CancellationToken cancellationToken = default)
+ {
+ Record(command, eventData.Duration, "reader");
+ return ValueTask.FromResult(result);
+ }
+
+ public override int NonQueryExecuted(DbCommand command, CommandExecutedEventData eventData, int result)
+ {
+ Record(command, eventData.Duration, "non_query");
+ return result;
+ }
+
+ public override ValueTask NonQueryExecutedAsync(
+ DbCommand command,
+ CommandExecutedEventData eventData,
+ int result,
+ CancellationToken cancellationToken = default)
+ {
+ Record(command, eventData.Duration, "non_query");
+ return ValueTask.FromResult(result);
+ }
+
+ public override object? ScalarExecuted(DbCommand command, CommandExecutedEventData eventData, object? result)
+ {
+ Record(command, eventData.Duration, "scalar");
+ return result;
+ }
+
+ public override ValueTask