Search/AdvisoryAI and DAL conversion to EF finishes up. Preparation for microservices consolidation.

This commit is contained in:
master
2026-02-25 18:19:22 +02:00
parent 4db038123b
commit 63c70a6d37
447 changed files with 52257 additions and 2636 deletions

View File

@@ -17,11 +17,13 @@ public sealed class DeliveryRepository : IDeliveryRepository
private readonly NotifyDataSource _dataSource;
private readonly ILogger<DeliveryRepository> _logger;
private readonly TimeProvider _timeProvider;
public DeliveryRepository(NotifyDataSource dataSource, ILogger<DeliveryRepository> logger)
public DeliveryRepository(NotifyDataSource dataSource, ILogger<DeliveryRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <inheritdoc />
@@ -178,7 +180,7 @@ public sealed class DeliveryRepository : IDeliveryRepository
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
// Use variables for enum values so the LINQ translator parameterizes them
// instead of inlining with ::notify.delivery_status casts that require
// the enum type to be resolved in the connection's type catalog.
@@ -243,9 +245,10 @@ public sealed class DeliveryRepository : IDeliveryRepository
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.deliveries SET status = 'queued'::notify.delivery_status, queued_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'pending'",
new object[] { tenantId, id },
"UPDATE notify.deliveries SET status = 'queued'::notify.delivery_status, queued_at = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'pending'",
new object[] { now, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
@@ -260,10 +263,12 @@ public sealed class DeliveryRepository : IDeliveryRepository
// Use named NpgsqlParameters for all values because the nullable external_id
// requires explicit type info (EF Core cannot map DBNull.Value without it),
// and mixing named + positional parameters is not supported.
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.deliveries SET status = 'sent'::notify.delivery_status, sent_at = NOW(), external_id = COALESCE(@p_ext_id, external_id) WHERE tenant_id = @p_tid AND id = @p_id AND status IN ('pending', 'queued', 'sending')",
"UPDATE notify.deliveries SET status = 'sent'::notify.delivery_status, sent_at = @p_now, external_id = COALESCE(@p_ext_id, external_id) WHERE tenant_id = @p_tid AND id = @p_id AND status IN ('pending', 'queued', 'sending')",
new object[]
{
new NpgsqlParameter("@p_now", NpgsqlDbType.TimestampTz) { Value = now },
new NpgsqlParameter("@p_ext_id", NpgsqlDbType.Text) { Value = (object?)externalId ?? DBNull.Value },
new NpgsqlParameter("@p_tid", NpgsqlDbType.Text) { Value = tenantId },
new NpgsqlParameter("@p_id", NpgsqlDbType.Uuid) { Value = id }
@@ -279,9 +284,10 @@ public sealed class DeliveryRepository : IDeliveryRepository
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.deliveries SET status = 'delivered'::notify.delivery_status, delivered_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'sent'",
new object[] { tenantId, id },
"UPDATE notify.deliveries SET status = 'delivered'::notify.delivery_status, delivered_at = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'sent'",
new object[] { now, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
@@ -298,9 +304,11 @@ public sealed class DeliveryRepository : IDeliveryRepository
.ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
int rows;
if (retryDelay.HasValue)
{
var nextRetryAt = now + retryDelay.Value;
rows = await dbContext.Database.ExecuteSqlRawAsync(
"""
UPDATE notify.deliveries
@@ -310,14 +318,14 @@ public sealed class DeliveryRepository : IDeliveryRepository
END,
attempt = attempt + 1,
error_message = {0},
failed_at = CASE WHEN attempt + 1 >= max_attempts THEN NOW() ELSE failed_at END,
failed_at = CASE WHEN attempt + 1 >= max_attempts THEN {1} ELSE failed_at END,
next_retry_at = CASE
WHEN attempt + 1 < max_attempts THEN NOW() + {1}
WHEN attempt + 1 < max_attempts THEN {2}
ELSE NULL
END
WHERE tenant_id = {2} AND id = {3}
WHERE tenant_id = {3} AND id = {4}
""",
new object[] { errorMessage, retryDelay.Value, tenantId, id },
new object[] { errorMessage, now, nextRetryAt, tenantId, id },
cancellationToken).ConfigureAwait(false);
}
else
@@ -328,11 +336,11 @@ public sealed class DeliveryRepository : IDeliveryRepository
SET status = 'failed'::notify.delivery_status,
attempt = attempt + 1,
error_message = {0},
failed_at = NOW(),
failed_at = {1},
next_retry_at = NULL
WHERE tenant_id = {1} AND id = {2}
WHERE tenant_id = {2} AND id = {3}
""",
new object[] { errorMessage, tenantId, id },
new object[] { errorMessage, now, tenantId, id },
cancellationToken).ConfigureAwait(false);
}

View File

@@ -10,11 +10,13 @@ public sealed class DigestRepository : IDigestRepository
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<DigestRepository> _logger;
private readonly TimeProvider _timeProvider;
public DigestRepository(NotifyDataSource dataSource, ILogger<DigestRepository> logger)
public DigestRepository(NotifyDataSource dataSource, ILogger<DigestRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<DigestEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -37,7 +39,7 @@ public sealed class DigestRepository : IDigestRepository
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
return await dbContext.Digests.AsNoTracking()
.Where(d => d.Status == DigestStatus.Collecting && d.CollectUntil <= now)
.OrderBy(d => d.CollectUntil)
@@ -97,9 +99,10 @@ public sealed class DigestRepository : IDigestRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.digests SET status = 'sent', sent_at = NOW() WHERE tenant_id = {0} AND id = {1}",
new object[] { tenantId, id },
"UPDATE notify.digests SET status = 'sent', sent_at = {0} WHERE tenant_id = {1} AND id = {2}",
new object[] { now, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}

View File

@@ -74,11 +74,13 @@ public sealed class EscalationStateRepository : IEscalationStateRepository
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<EscalationStateRepository> _logger;
private readonly TimeProvider _timeProvider;
public EscalationStateRepository(NotifyDataSource dataSource, ILogger<EscalationStateRepository> logger)
public EscalationStateRepository(NotifyDataSource dataSource, ILogger<EscalationStateRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<EscalationStateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -100,7 +102,7 @@ public sealed class EscalationStateRepository : IEscalationStateRepository
{
await using var connection = await _dataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
return await dbContext.EscalationStates.AsNoTracking()
.Where(s => s.Status == EscalationStatus.Active && s.NextEscalationAt <= now)
.OrderBy(s => s.NextEscalationAt)
@@ -134,9 +136,10 @@ public sealed class EscalationStateRepository : IEscalationStateRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.escalation_states SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'active'",
new object[] { acknowledgedBy, tenantId, id },
"UPDATE notify.escalation_states SET status = 'acknowledged', acknowledged_at = {0}, acknowledged_by = {1} WHERE tenant_id = {2} AND id = {3} AND status = 'active'",
new object[] { now, acknowledgedBy, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
@@ -145,9 +148,10 @@ public sealed class EscalationStateRepository : IEscalationStateRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.escalation_states SET status = 'resolved', resolved_at = NOW(), resolved_by = {0} WHERE tenant_id = {1} AND id = {2} AND status IN ('active', 'acknowledged')",
new object[] { resolvedBy, tenantId, id },
"UPDATE notify.escalation_states SET status = 'resolved', resolved_at = {0}, resolved_by = {1} WHERE tenant_id = {2} AND id = {3} AND status IN ('active', 'acknowledged')",
new object[] { now, resolvedBy, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}

View File

@@ -10,11 +10,13 @@ public sealed class InboxRepository : IInboxRepository
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<InboxRepository> _logger;
private readonly TimeProvider _timeProvider;
public InboxRepository(NotifyDataSource dataSource, ILogger<InboxRepository> logger)
public InboxRepository(NotifyDataSource dataSource, ILogger<InboxRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<InboxEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -66,9 +68,10 @@ public sealed class InboxRepository : IInboxRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = {0} AND id = {1} AND read = FALSE",
new object[] { tenantId, id },
"UPDATE notify.inbox SET read = TRUE, read_at = {0} WHERE tenant_id = {1} AND id = {2} AND read = FALSE",
new object[] { now, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
@@ -77,9 +80,10 @@ public sealed class InboxRepository : IInboxRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
return await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = {0} AND user_id = {1} AND read = FALSE",
new object[] { tenantId, userId },
"UPDATE notify.inbox SET read = TRUE, read_at = {0} WHERE tenant_id = {1} AND user_id = {2} AND read = FALSE",
new object[] { now, tenantId, userId },
cancellationToken).ConfigureAwait(false);
}
@@ -87,9 +91,10 @@ public sealed class InboxRepository : IInboxRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.inbox SET archived = TRUE, archived_at = NOW() WHERE tenant_id = {0} AND id = {1}",
new object[] { tenantId, id },
"UPDATE notify.inbox SET archived = TRUE, archived_at = {0} WHERE tenant_id = {1} AND id = {2}",
new object[] { now, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}

View File

@@ -10,11 +10,13 @@ public sealed class IncidentRepository : IIncidentRepository
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<IncidentRepository> _logger;
private readonly TimeProvider _timeProvider;
public IncidentRepository(NotifyDataSource dataSource, ILogger<IncidentRepository> logger)
public IncidentRepository(NotifyDataSource dataSource, ILogger<IncidentRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<IncidentEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
@@ -80,9 +82,10 @@ public sealed class IncidentRepository : IIncidentRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.incidents SET status = 'acknowledged', acknowledged_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status = 'open'",
new object[] { tenantId, id },
"UPDATE notify.incidents SET status = 'acknowledged', acknowledged_at = {0} WHERE tenant_id = {1} AND id = {2} AND status = 'open'",
new object[] { now, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
@@ -91,9 +94,10 @@ public sealed class IncidentRepository : IIncidentRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.incidents SET status = 'resolved', resolved_at = NOW() WHERE tenant_id = {0} AND id = {1} AND status IN ('open', 'acknowledged')",
new object[] { tenantId, id },
"UPDATE notify.incidents SET status = 'resolved', resolved_at = {0} WHERE tenant_id = {1} AND id = {2} AND status IN ('open', 'acknowledged')",
new object[] { now, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
@@ -102,9 +106,10 @@ public sealed class IncidentRepository : IIncidentRepository
{
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"UPDATE notify.incidents SET status = 'closed', closed_at = NOW() WHERE tenant_id = {0} AND id = {1}",
new object[] { tenantId, id },
"UPDATE notify.incidents SET status = 'closed', closed_at = {0} WHERE tenant_id = {1} AND id = {2}",
new object[] { now, tenantId, id },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}

View File

@@ -10,11 +10,13 @@ public sealed class LockRepository : ILockRepository
private const int CommandTimeoutSeconds = 30;
private readonly NotifyDataSource _dataSource;
private readonly ILogger<LockRepository> _logger;
private readonly TimeProvider _timeProvider;
public LockRepository(NotifyDataSource dataSource, ILogger<LockRepository> logger)
public LockRepository(NotifyDataSource dataSource, ILogger<LockRepository> logger, TimeProvider? timeProvider = null)
{
_dataSource = dataSource;
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
@@ -24,20 +26,22 @@ public sealed class LockRepository : ILockRepository
await using var connection = await _dataSource.OpenConnectionAsync(tenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var dbContext = NotifyDbContextFactory.Create(connection, CommandTimeoutSeconds, GetSchemaName());
var now = _timeProvider.GetUtcNow();
var expiresAt = now + ttl;
var rows = await dbContext.Database.ExecuteSqlRawAsync(
"""
WITH upsert AS (
INSERT INTO notify.locks (id, tenant_id, resource, owner, expires_at)
VALUES (gen_random_uuid(), {0}, {1}, {2}, NOW() + {3})
VALUES (gen_random_uuid(), {0}, {1}, {2}, {3})
ON CONFLICT (tenant_id, resource) DO UPDATE SET
owner = EXCLUDED.owner,
expires_at = EXCLUDED.expires_at
WHERE notify.locks.expires_at < NOW() OR notify.locks.owner = EXCLUDED.owner
WHERE notify.locks.expires_at < {4} OR notify.locks.owner = EXCLUDED.owner
RETURNING 1
)
SELECT COUNT(*) FROM upsert
""",
new object[] { tenantId, resource, owner, ttl },
new object[] { tenantId, resource, owner, expiresAt, now },
cancellationToken).ConfigureAwait(false);
return rows > 0;