up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
devportal-offline / build-offline (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-29 02:40:21 +02:00
parent 887b0a1c67
commit 7e7be4d2fd
54 changed files with 4907 additions and 3 deletions

View File

@@ -0,0 +1,30 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Digest status values.
/// </summary>
public static class DigestStatus
{
public const string Collecting = "collecting";
public const string Sending = "sending";
public const string Sent = "sent";
}
/// <summary>
/// Represents a digest of aggregated notifications.
/// </summary>
public sealed class DigestEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid ChannelId { get; init; }
public required string Recipient { get; init; }
public required string DigestKey { get; init; }
public int EventCount { get; init; }
public string Events { get; init; } = "[]";
public string Status { get; init; } = DigestStatus.Collecting;
public DateTimeOffset CollectUntil { get; init; }
public DateTimeOffset? SentAt { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,51 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents an escalation policy.
/// </summary>
public sealed class EscalationPolicyEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public bool Enabled { get; init; } = true;
public string Steps { get; init; } = "[]";
public int RepeatCount { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Escalation state status values.
/// </summary>
public static class EscalationStatus
{
public const string Active = "active";
public const string Acknowledged = "acknowledged";
public const string Resolved = "resolved";
public const string Expired = "expired";
}
/// <summary>
/// Represents the state of an escalation.
/// </summary>
public sealed class EscalationStateEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid PolicyId { get; init; }
public Guid? IncidentId { get; init; }
public required string CorrelationId { get; init; }
public int CurrentStep { get; init; }
public int RepeatIteration { get; init; }
public string Status { get; init; } = EscalationStatus.Active;
public DateTimeOffset StartedAt { get; init; }
public DateTimeOffset? NextEscalationAt { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
public string? AcknowledgedBy { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public string? ResolvedBy { get; init; }
public string Metadata { get; init; } = "{}";
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents an in-app notification inbox item.
/// </summary>
public sealed class InboxEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required Guid UserId { get; init; }
public required string Title { get; init; }
public string? Body { get; init; }
public required string EventType { get; init; }
public string EventPayload { get; init; } = "{}";
public bool Read { get; init; }
public bool Archived { get; init; }
public string? ActionUrl { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? ReadAt { get; init; }
public DateTimeOffset? ArchivedAt { get; init; }
}

View File

@@ -0,0 +1,46 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Incident severity values.
/// </summary>
public static class IncidentSeverity
{
public const string Critical = "critical";
public const string High = "high";
public const string Medium = "medium";
public const string Low = "low";
}
/// <summary>
/// Incident status values.
/// </summary>
public static class IncidentStatus
{
public const string Open = "open";
public const string Acknowledged = "acknowledged";
public const string Resolved = "resolved";
public const string Closed = "closed";
}
/// <summary>
/// Represents an incident.
/// </summary>
public sealed class IncidentEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Title { get; init; }
public string? Description { get; init; }
public string Severity { get; init; } = IncidentSeverity.Medium;
public string Status { get; init; } = IncidentStatus.Open;
public string? Source { get; init; }
public string? CorrelationId { get; init; }
public Guid? AssignedTo { get; init; }
public Guid? EscalationPolicyId { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? AcknowledgedAt { get; init; }
public DateTimeOffset? ResolvedAt { get; init; }
public DateTimeOffset? ClosedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents a maintenance window for suppressing notifications.
/// </summary>
public sealed class MaintenanceWindowEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public DateTimeOffset StartAt { get; init; }
public DateTimeOffset EndAt { get; init; }
public Guid[]? SuppressChannels { get; init; }
public string[]? SuppressEventTypes { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public string? CreatedBy { get; init; }
}

View File

@@ -0,0 +1,17 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents an audit log entry for the notify module.
/// </summary>
public sealed class NotifyAuditEntity
{
public long Id { get; init; }
public required string TenantId { get; init; }
public Guid? UserId { get; init; }
public required string Action { get; init; }
public required string ResourceType { get; init; }
public string? ResourceId { get; init; }
public string? Details { get; init; }
public string? CorrelationId { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,29 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Rotation type values.
/// </summary>
public static class RotationType
{
public const string Daily = "daily";
public const string Weekly = "weekly";
public const string Custom = "custom";
}
/// <summary>
/// Represents an on-call schedule.
/// </summary>
public sealed class OnCallScheduleEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public string Timezone { get; init; } = "UTC";
public string RotationType { get; init; } = Models.RotationType.Weekly;
public string Participants { get; init; } = "[]";
public string Overrides { get; init; } = "[]";
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents quiet hours configuration.
/// </summary>
public sealed class QuietHoursEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public Guid? UserId { get; init; }
public Guid? ChannelId { get; init; }
public required TimeOnly StartTime { get; init; }
public required TimeOnly EndTime { get; init; }
public string Timezone { get; init; } = "UTC";
public int[] DaysOfWeek { get; init; } = [0, 1, 2, 3, 4, 5, 6];
public bool Enabled { get; init; } = true;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,21 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents a notification routing rule.
/// </summary>
public sealed class RuleEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public bool Enabled { get; init; } = true;
public int Priority { get; init; }
public string[] EventTypes { get; init; } = [];
public string Filter { get; init; } = "{}";
public Guid[] ChannelIds { get; init; } = [];
public Guid? TemplateId { get; init; }
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,18 @@
namespace StellaOps.Notify.Storage.Postgres.Models;
/// <summary>
/// Represents a notification template.
/// </summary>
public sealed class TemplateEntity
{
public required Guid Id { get; init; }
public required string TenantId { get; init; }
public required string Name { get; init; }
public required ChannelType ChannelType { get; init; }
public string? SubjectTemplate { get; init; }
public required string BodyTemplate { get; init; }
public string Locale { get; init; } = "en";
public string Metadata { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}

View File

@@ -0,0 +1,142 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class DigestRepository : RepositoryBase<NotifyDataSource>, IDigestRepository
{
public DigestRepository(NotifyDataSource dataSource, ILogger<DigestRepository> logger)
: base(dataSource, logger) { }
public async Task<DigestEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapDigest, cancellationToken).ConfigureAwait(false);
}
public async Task<DigestEntity?> GetByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE tenant_id = @tenant_id AND channel_id = @channel_id AND recipient = @recipient AND digest_key = @digest_key
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "channel_id", channelId);
AddParameter(cmd, "recipient", recipient);
AddParameter(cmd, "digest_key", digestKey);
}, MapDigest, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<DigestEntity>> GetReadyToSendAsync(int limit = 100, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until, sent_at, created_at, updated_at
FROM notify.digests WHERE status = 'collecting' AND collect_until <= NOW()
ORDER BY collect_until LIMIT @limit
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
var results = new List<DigestEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapDigest(reader));
return results;
}
public async Task<DigestEntity> UpsertAsync(DigestEntity digest, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.digests (id, tenant_id, channel_id, recipient, digest_key, event_count, events, status, collect_until)
VALUES (@id, @tenant_id, @channel_id, @recipient, @digest_key, @event_count, @events::jsonb, @status, @collect_until)
ON CONFLICT (tenant_id, channel_id, recipient, digest_key) DO UPDATE SET
event_count = notify.digests.event_count + EXCLUDED.event_count,
events = notify.digests.events || EXCLUDED.events,
collect_until = GREATEST(notify.digests.collect_until, EXCLUDED.collect_until)
RETURNING *
""";
var id = digest.Id == Guid.Empty ? Guid.NewGuid() : digest.Id;
await using var connection = await DataSource.OpenConnectionAsync(digest.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", digest.TenantId);
AddParameter(command, "channel_id", digest.ChannelId);
AddParameter(command, "recipient", digest.Recipient);
AddParameter(command, "digest_key", digest.DigestKey);
AddParameter(command, "event_count", digest.EventCount);
AddJsonbParameter(command, "events", digest.Events);
AddParameter(command, "status", digest.Status);
AddParameter(command, "collect_until", digest.CollectUntil);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapDigest(reader);
}
public async Task<bool> AddEventAsync(string tenantId, Guid id, string eventJson, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.digests SET event_count = event_count + 1, events = events || @event::jsonb
WHERE tenant_id = @tenant_id AND id = @id AND status = 'collecting'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddJsonbParameter(cmd, "event", eventJson);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> MarkSendingAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.digests SET status = 'sending' WHERE tenant_id = @tenant_id AND id = @id AND status = 'collecting'";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> MarkSentAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.digests SET status = 'sent', sent_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.digests WHERE status = 'sent' AND sent_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static DigestEntity MapDigest(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
ChannelId = reader.GetGuid(2),
Recipient = reader.GetString(3),
DigestKey = reader.GetString(4),
EventCount = reader.GetInt32(5),
Events = reader.GetString(6),
Status = reader.GetString(7),
CollectUntil = reader.GetFieldValue<DateTimeOffset>(8),
SentAt = GetNullableDateTimeOffset(reader, 9),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(10),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(11)
};
}

View File

@@ -0,0 +1,252 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class EscalationPolicyRepository : RepositoryBase<NotifyDataSource>, IEscalationPolicyRepository
{
public EscalationPolicyRepository(NotifyDataSource dataSource, ILogger<EscalationPolicyRepository> logger)
: base(dataSource, logger) { }
public async Task<EscalationPolicyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapPolicy, cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationPolicyEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapPolicy, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<EscalationPolicyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, steps, repeat_count, metadata, created_at, updated_at
FROM notify.escalation_policies WHERE tenant_id = @tenant_id ORDER BY name
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapPolicy, cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationPolicyEntity> CreateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.escalation_policies (id, tenant_id, name, description, enabled, steps, repeat_count, metadata)
VALUES (@id, @tenant_id, @name, @description, @enabled, @steps::jsonb, @repeat_count, @metadata::jsonb)
RETURNING *
""";
var id = policy.Id == Guid.Empty ? Guid.NewGuid() : policy.Id;
await using var connection = await DataSource.OpenConnectionAsync(policy.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", policy.TenantId);
AddParameter(command, "name", policy.Name);
AddParameter(command, "description", policy.Description);
AddParameter(command, "enabled", policy.Enabled);
AddJsonbParameter(command, "steps", policy.Steps);
AddParameter(command, "repeat_count", policy.RepeatCount);
AddJsonbParameter(command, "metadata", policy.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapPolicy(reader);
}
public async Task<bool> UpdateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_policies SET name = @name, description = @description, enabled = @enabled,
steps = @steps::jsonb, repeat_count = @repeat_count, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(policy.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", policy.TenantId);
AddParameter(cmd, "id", policy.Id);
AddParameter(cmd, "name", policy.Name);
AddParameter(cmd, "description", policy.Description);
AddParameter(cmd, "enabled", policy.Enabled);
AddJsonbParameter(cmd, "steps", policy.Steps);
AddParameter(cmd, "repeat_count", policy.RepeatCount);
AddJsonbParameter(cmd, "metadata", policy.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.escalation_policies WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static EscalationPolicyEntity MapPolicy(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Enabled = reader.GetBoolean(4),
Steps = reader.GetString(5),
RepeatCount = reader.GetInt32(6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
}
public sealed class EscalationStateRepository : RepositoryBase<NotifyDataSource>, IEscalationStateRepository
{
public EscalationStateRepository(NotifyDataSource dataSource, ILogger<EscalationStateRepository> logger)
: base(dataSource, logger) { }
public async Task<EscalationStateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapState, cancellationToken).ConfigureAwait(false);
}
public async Task<EscalationStateEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id AND status = 'active'
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapState, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<EscalationStateEntity>> GetActiveAsync(int limit = 100, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status,
started_at, next_escalation_at, acknowledged_at, acknowledged_by, resolved_at, resolved_by, metadata
FROM notify.escalation_states WHERE status = 'active' AND next_escalation_at <= NOW()
ORDER BY next_escalation_at LIMIT @limit
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
var results = new List<EscalationStateEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
results.Add(MapState(reader));
return results;
}
public async Task<EscalationStateEntity> CreateAsync(EscalationStateEntity state, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.escalation_states (id, tenant_id, policy_id, incident_id, correlation_id, current_step, repeat_iteration, status, next_escalation_at, metadata)
VALUES (@id, @tenant_id, @policy_id, @incident_id, @correlation_id, @current_step, @repeat_iteration, @status, @next_escalation_at, @metadata::jsonb)
RETURNING *
""";
var id = state.Id == Guid.Empty ? Guid.NewGuid() : state.Id;
await using var connection = await DataSource.OpenConnectionAsync(state.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", state.TenantId);
AddParameter(command, "policy_id", state.PolicyId);
AddParameter(command, "incident_id", state.IncidentId);
AddParameter(command, "correlation_id", state.CorrelationId);
AddParameter(command, "current_step", state.CurrentStep);
AddParameter(command, "repeat_iteration", state.RepeatIteration);
AddParameter(command, "status", state.Status);
AddParameter(command, "next_escalation_at", state.NextEscalationAt);
AddJsonbParameter(command, "metadata", state.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapState(reader);
}
public async Task<bool> EscalateAsync(string tenantId, Guid id, int newStep, DateTimeOffset? nextEscalationAt, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET current_step = @new_step, next_escalation_at = @next_escalation_at
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "new_step", newStep);
AddParameter(cmd, "next_escalation_at", nextEscalationAt);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> AcknowledgeAsync(string tenantId, Guid id, string acknowledgedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET status = 'acknowledged', acknowledged_at = NOW(), acknowledged_by = @acknowledged_by
WHERE tenant_id = @tenant_id AND id = @id AND status = 'active'
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "acknowledged_by", acknowledgedBy);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> ResolveAsync(string tenantId, Guid id, string resolvedBy, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.escalation_states SET status = 'resolved', resolved_at = NOW(), resolved_by = @resolved_by
WHERE tenant_id = @tenant_id AND id = @id AND status IN ('active', 'acknowledged')
""";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "resolved_by", resolvedBy);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static EscalationStateEntity MapState(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
PolicyId = reader.GetGuid(2),
IncidentId = GetNullableGuid(reader, 3),
CorrelationId = reader.GetString(4),
CurrentStep = reader.GetInt32(5),
RepeatIteration = reader.GetInt32(6),
Status = reader.GetString(7),
StartedAt = reader.GetFieldValue<DateTimeOffset>(8),
NextEscalationAt = GetNullableDateTimeOffset(reader, 9),
AcknowledgedAt = GetNullableDateTimeOffset(reader, 10),
AcknowledgedBy = GetNullableString(reader, 11),
ResolvedAt = GetNullableDateTimeOffset(reader, 12),
ResolvedBy = GetNullableString(reader, 13),
Metadata = reader.GetString(14)
};
}

View File

@@ -0,0 +1,15 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IDigestRepository
{
Task<DigestEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<DigestEntity?> GetByKeyAsync(string tenantId, Guid channelId, string recipient, string digestKey, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DigestEntity>> GetReadyToSendAsync(int limit = 100, CancellationToken cancellationToken = default);
Task<DigestEntity> UpsertAsync(DigestEntity digest, CancellationToken cancellationToken = default);
Task<bool> AddEventAsync(string tenantId, Guid id, string eventJson, CancellationToken cancellationToken = default);
Task<bool> MarkSendingAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> MarkSentAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,24 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IEscalationPolicyRepository
{
Task<EscalationPolicyEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<EscalationPolicyEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<EscalationPolicyEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<EscalationPolicyEntity> CreateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(EscalationPolicyEntity policy, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}
public interface IEscalationStateRepository
{
Task<EscalationStateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<EscalationStateEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<EscalationStateEntity>> GetActiveAsync(int limit = 100, CancellationToken cancellationToken = default);
Task<EscalationStateEntity> CreateAsync(EscalationStateEntity state, CancellationToken cancellationToken = default);
Task<bool> EscalateAsync(string tenantId, Guid id, int newStep, DateTimeOffset? nextEscalationAt, CancellationToken cancellationToken = default);
Task<bool> AcknowledgeAsync(string tenantId, Guid id, string acknowledgedBy, CancellationToken cancellationToken = default);
Task<bool> ResolveAsync(string tenantId, Guid id, string resolvedBy, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IInboxRepository
{
Task<InboxEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<InboxEntity>> GetForUserAsync(string tenantId, Guid userId, bool unreadOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default);
Task<int> GetUnreadCountAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<InboxEntity> CreateAsync(InboxEntity inbox, CancellationToken cancellationToken = default);
Task<bool> MarkReadAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<int> MarkAllReadAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<bool> ArchiveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IIncidentRepository
{
Task<IncidentEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<IncidentEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<IncidentEntity>> ListAsync(string tenantId, string? status = null, string? severity = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<IncidentEntity> CreateAsync(IncidentEntity incident, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(IncidentEntity incident, CancellationToken cancellationToken = default);
Task<bool> AcknowledgeAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> ResolveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> CloseAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<bool> AssignAsync(string tenantId, Guid id, Guid assignedTo, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IMaintenanceWindowRepository
{
Task<MaintenanceWindowEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MaintenanceWindowEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<MaintenanceWindowEntity>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default);
Task<MaintenanceWindowEntity> CreateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<int> DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,12 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface INotifyAuditRepository
{
Task<long> CreateAsync(NotifyAuditEntity audit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default);
Task<IReadOnlyList<NotifyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default);
Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IOnCallScheduleRepository
{
Task<OnCallScheduleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<OnCallScheduleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OnCallScheduleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<OnCallScheduleEntity> CreateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IQuietHoursRepository
{
Task<QuietHoursEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<QuietHoursEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<QuietHoursEntity>> GetForUserAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default);
Task<QuietHoursEntity> CreateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface IRuleRepository
{
Task<RuleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<RuleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RuleEntity>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RuleEntity>> GetMatchingRulesAsync(string tenantId, string eventType, CancellationToken cancellationToken = default);
Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(RuleEntity rule, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public interface ITemplateRepository
{
Task<TemplateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
Task<TemplateEntity?> GetByNameAsync(string tenantId, string name, ChannelType channelType, string locale = "en", CancellationToken cancellationToken = default);
Task<IReadOnlyList<TemplateEntity>> ListAsync(string tenantId, ChannelType? channelType = null, CancellationToken cancellationToken = default);
Task<TemplateEntity> CreateAsync(TemplateEntity template, CancellationToken cancellationToken = default);
Task<bool> UpdateAsync(TemplateEntity template, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class InboxRepository : RepositoryBase<NotifyDataSource>, IInboxRepository
{
public InboxRepository(NotifyDataSource dataSource, ILogger<InboxRepository> logger)
: base(dataSource, logger) { }
public async Task<InboxEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, title, body, event_type, event_payload, read, archived, action_url, correlation_id, created_at, read_at, archived_at
FROM notify.inbox WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapInbox, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<InboxEntity>> GetForUserAsync(string tenantId, Guid userId, bool unreadOnly = false, int limit = 50, int offset = 0, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, user_id, title, body, event_type, event_payload, read, archived, action_url, correlation_id, created_at, read_at, archived_at
FROM notify.inbox WHERE tenant_id = @tenant_id AND user_id = @user_id AND archived = FALSE
""";
if (unreadOnly) sql += " AND read = FALSE";
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "user_id", userId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapInbox, cancellationToken).ConfigureAwait(false);
}
public async Task<int> GetUnreadCountAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = "SELECT COUNT(*) FROM notify.inbox WHERE tenant_id = @tenant_id AND user_id = @user_id AND read = FALSE AND archived = FALSE";
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "user_id", userId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return Convert.ToInt32(result);
}
public async Task<InboxEntity> CreateAsync(InboxEntity inbox, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.inbox (id, tenant_id, user_id, title, body, event_type, event_payload, action_url, correlation_id)
VALUES (@id, @tenant_id, @user_id, @title, @body, @event_type, @event_payload::jsonb, @action_url, @correlation_id)
RETURNING *
""";
var id = inbox.Id == Guid.Empty ? Guid.NewGuid() : inbox.Id;
await using var connection = await DataSource.OpenConnectionAsync(inbox.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", inbox.TenantId);
AddParameter(command, "user_id", inbox.UserId);
AddParameter(command, "title", inbox.Title);
AddParameter(command, "body", inbox.Body);
AddParameter(command, "event_type", inbox.EventType);
AddJsonbParameter(command, "event_payload", inbox.EventPayload);
AddParameter(command, "action_url", inbox.ActionUrl);
AddParameter(command, "correlation_id", inbox.CorrelationId);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapInbox(reader);
}
public async Task<bool> MarkReadAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND read = FALSE";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> MarkAllReadAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET read = TRUE, read_at = NOW() WHERE tenant_id = @tenant_id AND user_id = @user_id AND read = FALSE";
return await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
cancellationToken).ConfigureAwait(false);
}
public async Task<bool> ArchiveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.inbox SET archived = TRUE, archived_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.inbox WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.inbox WHERE archived = TRUE AND archived_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static InboxEntity MapInbox(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = reader.GetGuid(2),
Title = reader.GetString(3),
Body = GetNullableString(reader, 4),
EventType = reader.GetString(5),
EventPayload = reader.GetString(6),
Read = reader.GetBoolean(7),
Archived = reader.GetBoolean(8),
ActionUrl = GetNullableString(reader, 9),
CorrelationId = GetNullableString(reader, 10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
ReadAt = GetNullableDateTimeOffset(reader, 12),
ArchivedAt = GetNullableDateTimeOffset(reader, 13)
};
}

View File

@@ -0,0 +1,167 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class IncidentRepository : RepositoryBase<NotifyDataSource>, IIncidentRepository
{
public IncidentRepository(NotifyDataSource dataSource, ILogger<IncidentRepository> logger)
: base(dataSource, logger) { }
public async Task<IncidentEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapIncident, cancellationToken).ConfigureAwait(false);
}
public async Task<IncidentEntity?> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapIncident, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<IncidentEntity>> ListAsync(string tenantId, string? status = null, string? severity = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id,
metadata, created_at, acknowledged_at, resolved_at, closed_at, created_by
FROM notify.incidents WHERE tenant_id = @tenant_id
""";
if (status != null) sql += " AND status = @status";
if (severity != null) sql += " AND severity = @severity";
sql += " ORDER BY created_at DESC LIMIT @limit OFFSET @offset";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (status != null) AddParameter(cmd, "status", status);
if (severity != null) AddParameter(cmd, "severity", severity);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapIncident, cancellationToken).ConfigureAwait(false);
}
public async Task<IncidentEntity> CreateAsync(IncidentEntity incident, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.incidents (id, tenant_id, title, description, severity, status, source, correlation_id, assigned_to, escalation_policy_id, metadata, created_by)
VALUES (@id, @tenant_id, @title, @description, @severity, @status, @source, @correlation_id, @assigned_to, @escalation_policy_id, @metadata::jsonb, @created_by)
RETURNING *
""";
var id = incident.Id == Guid.Empty ? Guid.NewGuid() : incident.Id;
await using var connection = await DataSource.OpenConnectionAsync(incident.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", incident.TenantId);
AddParameter(command, "title", incident.Title);
AddParameter(command, "description", incident.Description);
AddParameter(command, "severity", incident.Severity);
AddParameter(command, "status", incident.Status);
AddParameter(command, "source", incident.Source);
AddParameter(command, "correlation_id", incident.CorrelationId);
AddParameter(command, "assigned_to", incident.AssignedTo);
AddParameter(command, "escalation_policy_id", incident.EscalationPolicyId);
AddJsonbParameter(command, "metadata", incident.Metadata);
AddParameter(command, "created_by", incident.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapIncident(reader);
}
public async Task<bool> UpdateAsync(IncidentEntity incident, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.incidents SET title = @title, description = @description, severity = @severity, status = @status,
source = @source, assigned_to = @assigned_to, escalation_policy_id = @escalation_policy_id, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(incident.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", incident.TenantId);
AddParameter(cmd, "id", incident.Id);
AddParameter(cmd, "title", incident.Title);
AddParameter(cmd, "description", incident.Description);
AddParameter(cmd, "severity", incident.Severity);
AddParameter(cmd, "status", incident.Status);
AddParameter(cmd, "source", incident.Source);
AddParameter(cmd, "assigned_to", incident.AssignedTo);
AddParameter(cmd, "escalation_policy_id", incident.EscalationPolicyId);
AddJsonbParameter(cmd, "metadata", incident.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> AcknowledgeAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'acknowledged', acknowledged_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND status = 'open'";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> ResolveAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'resolved', resolved_at = NOW() WHERE tenant_id = @tenant_id AND id = @id AND status IN ('open', 'acknowledged')";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> CloseAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET status = 'closed', closed_at = NOW() WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> AssignAsync(string tenantId, Guid id, Guid assignedTo, CancellationToken cancellationToken = default)
{
const string sql = "UPDATE notify.incidents SET assigned_to = @assigned_to WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "assigned_to", assignedTo);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static IncidentEntity MapIncident(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Title = reader.GetString(2),
Description = GetNullableString(reader, 3),
Severity = reader.GetString(4),
Status = reader.GetString(5),
Source = GetNullableString(reader, 6),
CorrelationId = GetNullableString(reader, 7),
AssignedTo = GetNullableGuid(reader, 8),
EscalationPolicyId = GetNullableGuid(reader, 9),
Metadata = reader.GetString(10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
AcknowledgedAt = GetNullableDateTimeOffset(reader, 12),
ResolvedAt = GetNullableDateTimeOffset(reader, 13),
ClosedAt = GetNullableDateTimeOffset(reader, 14),
CreatedBy = GetNullableString(reader, 15)
};
}

View File

@@ -0,0 +1,123 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class MaintenanceWindowRepository : RepositoryBase<NotifyDataSource>, IMaintenanceWindowRepository
{
public MaintenanceWindowRepository(NotifyDataSource dataSource, ILogger<MaintenanceWindowRepository> logger)
: base(dataSource, logger) { }
public async Task<MaintenanceWindowEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapWindow, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<MaintenanceWindowEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id ORDER BY start_at DESC
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapWindow, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<MaintenanceWindowEntity>> GetActiveAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_at, created_by
FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND start_at <= NOW() AND end_at > NOW()
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapWindow, cancellationToken).ConfigureAwait(false);
}
public async Task<MaintenanceWindowEntity> CreateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.maintenance_windows (id, tenant_id, name, description, start_at, end_at, suppress_channels, suppress_event_types, created_by)
VALUES (@id, @tenant_id, @name, @description, @start_at, @end_at, @suppress_channels, @suppress_event_types, @created_by)
RETURNING *
""";
var id = window.Id == Guid.Empty ? Guid.NewGuid() : window.Id;
await using var connection = await DataSource.OpenConnectionAsync(window.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", window.TenantId);
AddParameter(command, "name", window.Name);
AddParameter(command, "description", window.Description);
AddParameter(command, "start_at", window.StartAt);
AddParameter(command, "end_at", window.EndAt);
AddParameter(command, "suppress_channels", window.SuppressChannels);
AddTextArrayParameter(command, "suppress_event_types", window.SuppressEventTypes ?? []);
AddParameter(command, "created_by", window.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapWindow(reader);
}
public async Task<bool> UpdateAsync(MaintenanceWindowEntity window, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.maintenance_windows SET name = @name, description = @description, start_at = @start_at, end_at = @end_at,
suppress_channels = @suppress_channels, suppress_event_types = @suppress_event_types
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(window.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", window.TenantId);
AddParameter(cmd, "id", window.Id);
AddParameter(cmd, "name", window.Name);
AddParameter(cmd, "description", window.Description);
AddParameter(cmd, "start_at", window.StartAt);
AddParameter(cmd, "end_at", window.EndAt);
AddParameter(cmd, "suppress_channels", window.SuppressChannels);
AddTextArrayParameter(cmd, "suppress_event_types", window.SuppressEventTypes ?? []);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.maintenance_windows WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<int> DeleteExpiredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.maintenance_windows WHERE end_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static MaintenanceWindowEntity MapWindow(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
StartAt = reader.GetFieldValue<DateTimeOffset>(4),
EndAt = reader.GetFieldValue<DateTimeOffset>(5),
SuppressChannels = reader.IsDBNull(6) ? null : reader.GetFieldValue<Guid[]>(6),
SuppressEventTypes = reader.IsDBNull(7) ? null : reader.GetFieldValue<string[]>(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
CreatedBy = GetNullableString(reader, 9)
};
}

View File

@@ -0,0 +1,100 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class NotifyAuditRepository : RepositoryBase<NotifyDataSource>, INotifyAuditRepository
{
public NotifyAuditRepository(NotifyDataSource dataSource, ILogger<NotifyAuditRepository> logger)
: base(dataSource, logger) { }
public async Task<long> CreateAsync(NotifyAuditEntity audit, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.audit (tenant_id, user_id, action, resource_type, resource_id, details, correlation_id)
VALUES (@tenant_id, @user_id, @action, @resource_type, @resource_id, @details::jsonb, @correlation_id)
RETURNING id
""";
await using var connection = await DataSource.OpenConnectionAsync(audit.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", audit.TenantId);
AddParameter(command, "user_id", audit.UserId);
AddParameter(command, "action", audit.Action);
AddParameter(command, "resource_type", audit.ResourceType);
AddParameter(command, "resource_id", audit.ResourceId);
AddJsonbParameter(command, "details", audit.Details);
AddParameter(command, "correlation_id", audit.CorrelationId);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return (long)result!;
}
public async Task<IReadOnlyList<NotifyAuditEntity>> ListAsync(string tenantId, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id
ORDER BY created_at DESC LIMIT @limit OFFSET @offset
""";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<NotifyAuditEntity>> GetByResourceAsync(string tenantId, string resourceType, string? resourceId = null, int limit = 100, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id AND resource_type = @resource_type
""";
if (resourceId != null) sql += " AND resource_id = @resource_id";
sql += " ORDER BY created_at DESC LIMIT @limit";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "resource_type", resourceType);
if (resourceId != null) AddParameter(cmd, "resource_id", resourceId);
AddParameter(cmd, "limit", limit);
}, MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<NotifyAuditEntity>> GetByCorrelationIdAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, action, resource_type, resource_id, details, correlation_id, created_at
FROM notify.audit WHERE tenant_id = @tenant_id AND correlation_id = @correlation_id
ORDER BY created_at
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "correlation_id", correlationId); },
MapAudit, cancellationToken).ConfigureAwait(false);
}
public async Task<int> DeleteOldAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.audit WHERE created_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static NotifyAuditEntity MapAudit(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(0),
TenantId = reader.GetString(1),
UserId = GetNullableGuid(reader, 2),
Action = reader.GetString(3),
ResourceType = reader.GetString(4),
ResourceId = GetNullableString(reader, 5),
Details = GetNullableString(reader, 6),
CorrelationId = GetNullableString(reader, 7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8)
};
}

View File

@@ -0,0 +1,116 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class OnCallScheduleRepository : RepositoryBase<NotifyDataSource>, IOnCallScheduleRepository
{
public OnCallScheduleRepository(NotifyDataSource dataSource, ILogger<OnCallScheduleRepository> logger)
: base(dataSource, logger) { }
public async Task<OnCallScheduleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapSchedule, cancellationToken).ConfigureAwait(false);
}
public async Task<OnCallScheduleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapSchedule, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OnCallScheduleEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata, created_at, updated_at
FROM notify.on_call_schedules WHERE tenant_id = @tenant_id ORDER BY name
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapSchedule, cancellationToken).ConfigureAwait(false);
}
public async Task<OnCallScheduleEntity> CreateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.on_call_schedules (id, tenant_id, name, description, timezone, rotation_type, participants, overrides, metadata)
VALUES (@id, @tenant_id, @name, @description, @timezone, @rotation_type, @participants::jsonb, @overrides::jsonb, @metadata::jsonb)
RETURNING *
""";
var id = schedule.Id == Guid.Empty ? Guid.NewGuid() : schedule.Id;
await using var connection = await DataSource.OpenConnectionAsync(schedule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", schedule.TenantId);
AddParameter(command, "name", schedule.Name);
AddParameter(command, "description", schedule.Description);
AddParameter(command, "timezone", schedule.Timezone);
AddParameter(command, "rotation_type", schedule.RotationType);
AddJsonbParameter(command, "participants", schedule.Participants);
AddJsonbParameter(command, "overrides", schedule.Overrides);
AddJsonbParameter(command, "metadata", schedule.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapSchedule(reader);
}
public async Task<bool> UpdateAsync(OnCallScheduleEntity schedule, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.on_call_schedules SET name = @name, description = @description, timezone = @timezone,
rotation_type = @rotation_type, participants = @participants::jsonb, overrides = @overrides::jsonb, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(schedule.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", schedule.TenantId);
AddParameter(cmd, "id", schedule.Id);
AddParameter(cmd, "name", schedule.Name);
AddParameter(cmd, "description", schedule.Description);
AddParameter(cmd, "timezone", schedule.Timezone);
AddParameter(cmd, "rotation_type", schedule.RotationType);
AddJsonbParameter(cmd, "participants", schedule.Participants);
AddJsonbParameter(cmd, "overrides", schedule.Overrides);
AddJsonbParameter(cmd, "metadata", schedule.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.on_call_schedules WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static OnCallScheduleEntity MapSchedule(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Timezone = reader.GetString(4),
RotationType = reader.GetString(5),
Participants = reader.GetString(6),
Overrides = reader.GetString(7),
Metadata = reader.GetString(8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
}

View File

@@ -0,0 +1,116 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class QuietHoursRepository : RepositoryBase<NotifyDataSource>, IQuietHoursRepository
{
public QuietHoursRepository(NotifyDataSource dataSource, ILogger<QuietHoursRepository> logger)
: base(dataSource, logger) { }
public async Task<QuietHoursEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapQuietHours, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<QuietHoursEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id ORDER BY start_time
""";
return await QueryAsync(tenantId, sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapQuietHours, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<QuietHoursEntity>> GetForUserAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled, created_at, updated_at
FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND (user_id IS NULL OR user_id = @user_id) AND enabled = TRUE
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "user_id", userId); },
MapQuietHours, cancellationToken).ConfigureAwait(false);
}
public async Task<QuietHoursEntity> CreateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.quiet_hours (id, tenant_id, user_id, channel_id, start_time, end_time, timezone, days_of_week, enabled)
VALUES (@id, @tenant_id, @user_id, @channel_id, @start_time, @end_time, @timezone, @days_of_week, @enabled)
RETURNING *
""";
var id = quietHours.Id == Guid.Empty ? Guid.NewGuid() : quietHours.Id;
await using var connection = await DataSource.OpenConnectionAsync(quietHours.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", quietHours.TenantId);
AddParameter(command, "user_id", quietHours.UserId);
AddParameter(command, "channel_id", quietHours.ChannelId);
AddParameter(command, "start_time", quietHours.StartTime);
AddParameter(command, "end_time", quietHours.EndTime);
AddParameter(command, "timezone", quietHours.Timezone);
AddParameter(command, "days_of_week", quietHours.DaysOfWeek);
AddParameter(command, "enabled", quietHours.Enabled);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapQuietHours(reader);
}
public async Task<bool> UpdateAsync(QuietHoursEntity quietHours, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.quiet_hours SET user_id = @user_id, channel_id = @channel_id, start_time = @start_time, end_time = @end_time,
timezone = @timezone, days_of_week = @days_of_week, enabled = @enabled
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(quietHours.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", quietHours.TenantId);
AddParameter(cmd, "id", quietHours.Id);
AddParameter(cmd, "user_id", quietHours.UserId);
AddParameter(cmd, "channel_id", quietHours.ChannelId);
AddParameter(cmd, "start_time", quietHours.StartTime);
AddParameter(cmd, "end_time", quietHours.EndTime);
AddParameter(cmd, "timezone", quietHours.Timezone);
AddParameter(cmd, "days_of_week", quietHours.DaysOfWeek);
AddParameter(cmd, "enabled", quietHours.Enabled);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.quiet_hours WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static QuietHoursEntity MapQuietHours(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
UserId = GetNullableGuid(reader, 2),
ChannelId = GetNullableGuid(reader, 3),
StartTime = reader.GetFieldValue<TimeOnly>(4),
EndTime = reader.GetFieldValue<TimeOnly>(5),
Timezone = reader.GetString(6),
DaysOfWeek = reader.IsDBNull(7) ? [0, 1, 2, 3, 4, 5, 6] : reader.GetFieldValue<int[]>(7),
Enabled = reader.GetBoolean(8),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(9),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(10)
};
}

View File

@@ -0,0 +1,139 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class RuleRepository : RepositoryBase<NotifyDataSource>, IRuleRepository
{
public RuleRepository(NotifyDataSource dataSource, ILogger<RuleRepository> logger)
: base(dataSource, logger) { }
public async Task<RuleEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapRule, cancellationToken).ConfigureAwait(false);
}
public async Task<RuleEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "name", name); },
MapRule, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RuleEntity>> ListAsync(string tenantId, bool? enabled = null, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id
""";
if (enabled.HasValue) sql += " AND enabled = @enabled";
sql += " ORDER BY priority DESC, name";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (enabled.HasValue) AddParameter(cmd, "enabled", enabled.Value);
}, MapRule, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<RuleEntity>> GetMatchingRulesAsync(string tenantId, string eventType, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata, created_at, updated_at
FROM notify.rules WHERE tenant_id = @tenant_id AND enabled = TRUE AND @event_type = ANY(event_types)
ORDER BY priority DESC
""";
return await QueryAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "event_type", eventType); },
MapRule, cancellationToken).ConfigureAwait(false);
}
public async Task<RuleEntity> CreateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.rules (id, tenant_id, name, description, enabled, priority, event_types, filter, channel_ids, template_id, metadata)
VALUES (@id, @tenant_id, @name, @description, @enabled, @priority, @event_types, @filter::jsonb, @channel_ids, @template_id, @metadata::jsonb)
RETURNING *
""";
var id = rule.Id == Guid.Empty ? Guid.NewGuid() : rule.Id;
await using var connection = await DataSource.OpenConnectionAsync(rule.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", rule.TenantId);
AddParameter(command, "name", rule.Name);
AddParameter(command, "description", rule.Description);
AddParameter(command, "enabled", rule.Enabled);
AddParameter(command, "priority", rule.Priority);
AddTextArrayParameter(command, "event_types", rule.EventTypes);
AddJsonbParameter(command, "filter", rule.Filter);
AddParameter(command, "channel_ids", rule.ChannelIds);
AddParameter(command, "template_id", rule.TemplateId);
AddJsonbParameter(command, "metadata", rule.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapRule(reader);
}
public async Task<bool> UpdateAsync(RuleEntity rule, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.rules SET name = @name, description = @description, enabled = @enabled, priority = @priority,
event_types = @event_types, filter = @filter::jsonb, channel_ids = @channel_ids, template_id = @template_id, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(rule.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", rule.TenantId);
AddParameter(cmd, "id", rule.Id);
AddParameter(cmd, "name", rule.Name);
AddParameter(cmd, "description", rule.Description);
AddParameter(cmd, "enabled", rule.Enabled);
AddParameter(cmd, "priority", rule.Priority);
AddTextArrayParameter(cmd, "event_types", rule.EventTypes);
AddJsonbParameter(cmd, "filter", rule.Filter);
AddParameter(cmd, "channel_ids", rule.ChannelIds);
AddParameter(cmd, "template_id", rule.TemplateId);
AddJsonbParameter(cmd, "metadata", rule.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.rules WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static RuleEntity MapRule(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
Description = GetNullableString(reader, 3),
Enabled = reader.GetBoolean(4),
Priority = reader.GetInt32(5),
EventTypes = reader.IsDBNull(6) ? [] : reader.GetFieldValue<string[]>(6),
Filter = reader.GetString(7),
ChannelIds = reader.IsDBNull(8) ? [] : reader.GetFieldValue<Guid[]>(8),
TemplateId = GetNullableGuid(reader, 9),
Metadata = reader.GetString(10),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(11),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(12)
};
}

View File

@@ -0,0 +1,136 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Notify.Storage.Postgres.Models;
namespace StellaOps.Notify.Storage.Postgres.Repositories;
public sealed class TemplateRepository : RepositoryBase<NotifyDataSource>, ITemplateRepository
{
public TemplateRepository(NotifyDataSource dataSource, ILogger<TemplateRepository> logger)
: base(dataSource, logger) { }
public async Task<TemplateEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
MapTemplate, cancellationToken).ConfigureAwait(false);
}
public async Task<TemplateEntity?> GetByNameAsync(string tenantId, string name, ChannelType channelType, string locale = "en", CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id AND name = @name AND channel_type = @channel_type::notify.channel_type AND locale = @locale
""";
return await QuerySingleOrDefaultAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
AddParameter(cmd, "channel_type", ChannelTypeToString(channelType));
AddParameter(cmd, "locale", locale);
}, MapTemplate, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<TemplateEntity>> ListAsync(string tenantId, ChannelType? channelType = null, CancellationToken cancellationToken = default)
{
var sql = """
SELECT id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
FROM notify.templates WHERE tenant_id = @tenant_id
""";
if (channelType.HasValue) sql += " AND channel_type = @channel_type::notify.channel_type";
sql += " ORDER BY name, locale";
return await QueryAsync(tenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
if (channelType.HasValue) AddParameter(cmd, "channel_type", ChannelTypeToString(channelType.Value));
}, MapTemplate, cancellationToken).ConfigureAwait(false);
}
public async Task<TemplateEntity> CreateAsync(TemplateEntity template, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO notify.templates (id, tenant_id, name, channel_type, subject_template, body_template, locale, metadata)
VALUES (@id, @tenant_id, @name, @channel_type::notify.channel_type, @subject_template, @body_template, @locale, @metadata::jsonb)
RETURNING id, tenant_id, name, channel_type::text, subject_template, body_template, locale, metadata, created_at, updated_at
""";
var id = template.Id == Guid.Empty ? Guid.NewGuid() : template.Id;
await using var connection = await DataSource.OpenConnectionAsync(template.TenantId, "writer", cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", template.TenantId);
AddParameter(command, "name", template.Name);
AddParameter(command, "channel_type", ChannelTypeToString(template.ChannelType));
AddParameter(command, "subject_template", template.SubjectTemplate);
AddParameter(command, "body_template", template.BodyTemplate);
AddParameter(command, "locale", template.Locale);
AddJsonbParameter(command, "metadata", template.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapTemplate(reader);
}
public async Task<bool> UpdateAsync(TemplateEntity template, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE notify.templates SET name = @name, channel_type = @channel_type::notify.channel_type,
subject_template = @subject_template, body_template = @body_template, locale = @locale, metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(template.TenantId, sql, cmd =>
{
AddParameter(cmd, "tenant_id", template.TenantId);
AddParameter(cmd, "id", template.Id);
AddParameter(cmd, "name", template.Name);
AddParameter(cmd, "channel_type", ChannelTypeToString(template.ChannelType));
AddParameter(cmd, "subject_template", template.SubjectTemplate);
AddParameter(cmd, "body_template", template.BodyTemplate);
AddParameter(cmd, "locale", template.Locale);
AddJsonbParameter(cmd, "metadata", template.Metadata);
}, cancellationToken).ConfigureAwait(false);
return rows > 0;
}
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM notify.templates WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(tenantId, sql,
cmd => { AddParameter(cmd, "tenant_id", tenantId); AddParameter(cmd, "id", id); },
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static TemplateEntity MapTemplate(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(0),
TenantId = reader.GetString(1),
Name = reader.GetString(2),
ChannelType = ParseChannelType(reader.GetString(3)),
SubjectTemplate = GetNullableString(reader, 4),
BodyTemplate = reader.GetString(5),
Locale = reader.GetString(6),
Metadata = reader.GetString(7),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(8),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(9)
};
private static string ChannelTypeToString(ChannelType t) => t switch
{
ChannelType.Email => "email", ChannelType.Slack => "slack", ChannelType.Teams => "teams",
ChannelType.Webhook => "webhook", ChannelType.PagerDuty => "pagerduty", ChannelType.OpsGenie => "opsgenie",
_ => throw new ArgumentException($"Unknown: {t}")
};
private static ChannelType ParseChannelType(string s) => s switch
{
"email" => ChannelType.Email, "slack" => ChannelType.Slack, "teams" => ChannelType.Teams,
"webhook" => ChannelType.Webhook, "pagerduty" => ChannelType.PagerDuty, "opsgenie" => ChannelType.OpsGenie,
_ => throw new ArgumentException($"Unknown: {s}")
};
}

View File

@@ -34,7 +34,7 @@ public interface IReceiptBuilder
/// </summary>
public sealed class ReceiptBuilder : IReceiptBuilder
{
internal static readonly JsonSerializerOptions SerializerOptions = new()
public static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = null,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using System;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Npgsql;
using NpgsqlTypes;
@@ -164,7 +165,8 @@ returning {Columns};";
Hash = reader.GetString(idx.PolicyHash),
ActivatedAt = null
},
BaseMetrics = Deserialize<CvssBaseMetrics>(reader, idx.BaseMetrics),
BaseMetrics = Deserialize<CvssBaseMetrics>(reader, idx.BaseMetrics)
?? throw new InvalidOperationException("cvss_receipts.base_metrics missing"),
ThreatMetrics = Deserialize<CvssThreatMetrics>(reader, idx.ThreatMetrics),
EnvironmentalMetrics = Deserialize<CvssEnvironmentalMetrics>(reader, idx.EnvironmentalMetrics),
SupplementalMetrics = Deserialize<CvssSupplementalMetrics>(reader, idx.SupplementalMetrics),

View File

@@ -0,0 +1,82 @@
namespace StellaOps.Scheduler.Storage.Postgres.Models;
/// <summary>
/// Represents a job history entity in the scheduler schema.
/// </summary>
public sealed class JobHistoryEntity
{
/// <summary>
/// Unique history entry identifier.
/// </summary>
public long Id { get; init; }
/// <summary>
/// Original job ID.
/// </summary>
public required Guid JobId { get; init; }
/// <summary>
/// Tenant this job belonged to.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Optional project identifier.
/// </summary>
public string? ProjectId { get; init; }
/// <summary>
/// Type of job that was executed.
/// </summary>
public required string JobType { get; init; }
/// <summary>
/// Final job status.
/// </summary>
public JobStatus Status { get; init; }
/// <summary>
/// Attempt number when archived.
/// </summary>
public int Attempt { get; init; }
/// <summary>
/// SHA256 digest of payload.
/// </summary>
public required string PayloadDigest { get; init; }
/// <summary>
/// Job result as JSON.
/// </summary>
public string? Result { get; init; }
/// <summary>
/// Reason for failure/cancellation.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Worker that executed the job.
/// </summary>
public string? WorkerId { get; init; }
/// <summary>
/// Duration in milliseconds.
/// </summary>
public long? DurationMs { get; init; }
/// <summary>
/// When the job was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
/// <summary>
/// When the job completed.
/// </summary>
public DateTimeOffset CompletedAt { get; init; }
/// <summary>
/// When the job was archived to history.
/// </summary>
public DateTimeOffset ArchivedAt { get; init; }
}

View File

@@ -0,0 +1,37 @@
namespace StellaOps.Scheduler.Storage.Postgres.Models;
/// <summary>
/// Represents a distributed lock entity in the scheduler schema.
/// </summary>
public sealed class LockEntity
{
/// <summary>
/// Lock key (primary key).
/// </summary>
public required string LockKey { get; init; }
/// <summary>
/// Tenant this lock belongs to.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// ID of the holder that acquired the lock.
/// </summary>
public required string HolderId { get; init; }
/// <summary>
/// When the lock was acquired.
/// </summary>
public DateTimeOffset AcquiredAt { get; init; }
/// <summary>
/// When the lock expires.
/// </summary>
public DateTimeOffset ExpiresAt { get; init; }
/// <summary>
/// Lock metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
}

View File

@@ -0,0 +1,77 @@
namespace StellaOps.Scheduler.Storage.Postgres.Models;
/// <summary>
/// Represents a metrics entity in the scheduler schema.
/// </summary>
public sealed class MetricsEntity
{
/// <summary>
/// Unique metrics entry identifier.
/// </summary>
public long Id { get; init; }
/// <summary>
/// Tenant this metric belongs to.
/// </summary>
public required string TenantId { get; init; }
/// <summary>
/// Job type for this metric.
/// </summary>
public required string JobType { get; init; }
/// <summary>
/// Period start time.
/// </summary>
public DateTimeOffset PeriodStart { get; init; }
/// <summary>
/// Period end time.
/// </summary>
public DateTimeOffset PeriodEnd { get; init; }
/// <summary>
/// Number of jobs created in this period.
/// </summary>
public long JobsCreated { get; init; }
/// <summary>
/// Number of jobs completed in this period.
/// </summary>
public long JobsCompleted { get; init; }
/// <summary>
/// Number of jobs failed in this period.
/// </summary>
public long JobsFailed { get; init; }
/// <summary>
/// Number of jobs timed out in this period.
/// </summary>
public long JobsTimedOut { get; init; }
/// <summary>
/// Average duration in milliseconds.
/// </summary>
public long? AvgDurationMs { get; init; }
/// <summary>
/// 50th percentile duration.
/// </summary>
public long? P50DurationMs { get; init; }
/// <summary>
/// 95th percentile duration.
/// </summary>
public long? P95DurationMs { get; init; }
/// <summary>
/// 99th percentile duration.
/// </summary>
public long? P99DurationMs { get; init; }
/// <summary>
/// When this metric was created.
/// </summary>
public DateTimeOffset CreatedAt { get; init; }
}

View File

@@ -0,0 +1,72 @@
namespace StellaOps.Scheduler.Storage.Postgres.Models;
/// <summary>
/// Worker status values.
/// </summary>
public static class WorkerStatus
{
public const string Active = "active";
public const string Draining = "draining";
public const string Stopped = "stopped";
}
/// <summary>
/// Represents a worker entity in the scheduler schema.
/// </summary>
public sealed class WorkerEntity
{
/// <summary>
/// Unique worker identifier.
/// </summary>
public required string Id { get; init; }
/// <summary>
/// Optional tenant this worker is dedicated to.
/// </summary>
public string? TenantId { get; init; }
/// <summary>
/// Hostname of the worker.
/// </summary>
public required string Hostname { get; init; }
/// <summary>
/// Process ID of the worker.
/// </summary>
public int? ProcessId { get; init; }
/// <summary>
/// Job types this worker can process.
/// </summary>
public string[] JobTypes { get; init; } = [];
/// <summary>
/// Maximum concurrent jobs this worker can handle.
/// </summary>
public int MaxConcurrentJobs { get; init; } = 1;
/// <summary>
/// Current number of jobs being processed.
/// </summary>
public int CurrentJobs { get; init; }
/// <summary>
/// Worker status.
/// </summary>
public string Status { get; init; } = WorkerStatus.Active;
/// <summary>
/// Last heartbeat timestamp.
/// </summary>
public DateTimeOffset LastHeartbeatAt { get; init; }
/// <summary>
/// When the worker was registered.
/// </summary>
public DateTimeOffset RegisteredAt { get; init; }
/// <summary>
/// Worker metadata as JSON.
/// </summary>
public string Metadata { get; init; } = "{}";
}

View File

@@ -0,0 +1,145 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for distributed lock operations.
/// </summary>
public sealed class DistributedLockRepository : RepositoryBase<SchedulerDataSource>, IDistributedLockRepository
{
/// <summary>
/// Creates a new distributed lock repository.
/// </summary>
public DistributedLockRepository(SchedulerDataSource dataSource, ILogger<DistributedLockRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<bool> TryAcquireAsync(string tenantId, string lockKey, string holderId, TimeSpan duration, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO scheduler.locks (lock_key, tenant_id, holder_id, expires_at)
VALUES (@lock_key, @tenant_id, @holder_id, NOW() + @duration)
ON CONFLICT (lock_key) DO UPDATE SET
holder_id = EXCLUDED.holder_id,
tenant_id = EXCLUDED.tenant_id,
acquired_at = NOW(),
expires_at = NOW() + EXCLUDED.expires_at - EXCLUDED.acquired_at
WHERE scheduler.locks.expires_at < NOW()
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "lock_key", lockKey);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "holder_id", holderId);
AddParameter(command, "duration", duration);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<LockEntity?> GetAsync(string lockKey, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT lock_key, tenant_id, holder_id, acquired_at, expires_at, metadata
FROM scheduler.locks
WHERE lock_key = @lock_key AND expires_at > NOW()
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "lock_key", lockKey);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapLock(reader) : null;
}
/// <inheritdoc />
public async Task<bool> ExtendAsync(string lockKey, string holderId, TimeSpan extension, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE scheduler.locks
SET expires_at = expires_at + @extension
WHERE lock_key = @lock_key AND holder_id = @holder_id AND expires_at > NOW()
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "lock_key", lockKey);
AddParameter(command, "holder_id", holderId);
AddParameter(command, "extension", extension);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> ReleaseAsync(string lockKey, string holderId, CancellationToken cancellationToken = default)
{
const string sql = """
DELETE FROM scheduler.locks
WHERE lock_key = @lock_key AND holder_id = @holder_id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "lock_key", lockKey);
AddParameter(command, "holder_id", holderId);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM scheduler.locks WHERE expires_at < NOW()";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<LockEntity>> ListByTenantAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT lock_key, tenant_id, holder_id, acquired_at, expires_at, metadata
FROM scheduler.locks
WHERE tenant_id = @tenant_id AND expires_at > NOW()
ORDER BY acquired_at DESC
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
var results = new List<LockEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapLock(reader));
}
return results;
}
private static LockEntity MapLock(NpgsqlDataReader reader) => new()
{
LockKey = reader.GetString(reader.GetOrdinal("lock_key")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
HolderId = reader.GetString(reader.GetOrdinal("holder_id")),
AcquiredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("acquired_at")),
ExpiresAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("expires_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
};
}

View File

@@ -0,0 +1,39 @@
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for distributed lock operations.
/// </summary>
public interface IDistributedLockRepository
{
/// <summary>
/// Tries to acquire a lock.
/// </summary>
Task<bool> TryAcquireAsync(string tenantId, string lockKey, string holderId, TimeSpan duration, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a lock by key.
/// </summary>
Task<LockEntity?> GetAsync(string lockKey, CancellationToken cancellationToken = default);
/// <summary>
/// Extends a lock.
/// </summary>
Task<bool> ExtendAsync(string lockKey, string holderId, TimeSpan extension, CancellationToken cancellationToken = default);
/// <summary>
/// Releases a lock.
/// </summary>
Task<bool> ReleaseAsync(string lockKey, string holderId, CancellationToken cancellationToken = default);
/// <summary>
/// Cleans up expired locks.
/// </summary>
Task<int> CleanupExpiredAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Lists all locks for a tenant.
/// </summary>
Task<IReadOnlyList<LockEntity>> ListByTenantAsync(string tenantId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,61 @@
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for job history operations.
/// </summary>
public interface IJobHistoryRepository
{
/// <summary>
/// Archives a completed job.
/// </summary>
Task<JobHistoryEntity> ArchiveAsync(JobHistoryEntity history, CancellationToken cancellationToken = default);
/// <summary>
/// Gets history for a specific job.
/// </summary>
Task<IReadOnlyList<JobHistoryEntity>> GetByJobIdAsync(Guid jobId, CancellationToken cancellationToken = default);
/// <summary>
/// Lists job history for a tenant.
/// </summary>
Task<IReadOnlyList<JobHistoryEntity>> ListAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists job history by type.
/// </summary>
Task<IReadOnlyList<JobHistoryEntity>> ListByJobTypeAsync(
string tenantId,
string jobType,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists job history by status.
/// </summary>
Task<IReadOnlyList<JobHistoryEntity>> ListByStatusAsync(
string tenantId,
JobStatus status,
int limit = 100,
CancellationToken cancellationToken = default);
/// <summary>
/// Lists job history in a time range.
/// </summary>
Task<IReadOnlyList<JobHistoryEntity>> ListByTimeRangeAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
int limit = 1000,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes old history entries.
/// </summary>
Task<int> DeleteOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,45 @@
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for metrics operations.
/// </summary>
public interface IMetricsRepository
{
/// <summary>
/// Records or updates metrics for a period.
/// </summary>
Task<MetricsEntity> UpsertAsync(MetricsEntity metrics, CancellationToken cancellationToken = default);
/// <summary>
/// Gets metrics for a tenant and job type.
/// </summary>
Task<IReadOnlyList<MetricsEntity>> GetAsync(
string tenantId,
string jobType,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets aggregated metrics for a tenant.
/// </summary>
Task<IReadOnlyList<MetricsEntity>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets latest metrics for all job types.
/// </summary>
Task<IReadOnlyList<MetricsEntity>> GetLatestAsync(
string tenantId,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes old metrics.
/// </summary>
Task<int> DeleteOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,59 @@
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for trigger operations.
/// </summary>
public interface ITriggerRepository
{
/// <summary>
/// Gets a trigger by ID.
/// </summary>
Task<TriggerEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a trigger by name.
/// </summary>
Task<TriggerEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default);
/// <summary>
/// Lists all triggers for a tenant.
/// </summary>
Task<IReadOnlyList<TriggerEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets triggers that are due to fire.
/// </summary>
Task<IReadOnlyList<TriggerEntity>> GetDueTriggersAsync(int limit = 100, CancellationToken cancellationToken = default);
/// <summary>
/// Creates a new trigger.
/// </summary>
Task<TriggerEntity> CreateAsync(TriggerEntity trigger, CancellationToken cancellationToken = default);
/// <summary>
/// Updates a trigger.
/// </summary>
Task<bool> UpdateAsync(TriggerEntity trigger, CancellationToken cancellationToken = default);
/// <summary>
/// Records a trigger fire event.
/// </summary>
Task<bool> RecordFireAsync(string tenantId, Guid triggerId, Guid jobId, DateTimeOffset? nextFireAt, CancellationToken cancellationToken = default);
/// <summary>
/// Records a trigger misfire.
/// </summary>
Task<bool> RecordMisfireAsync(string tenantId, Guid triggerId, CancellationToken cancellationToken = default);
/// <summary>
/// Enables or disables a trigger.
/// </summary>
Task<bool> SetEnabledAsync(string tenantId, Guid id, bool enabled, CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a trigger.
/// </summary>
Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,54 @@
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// Repository interface for worker operations.
/// </summary>
public interface IWorkerRepository
{
/// <summary>
/// Gets a worker by ID.
/// </summary>
Task<WorkerEntity?> GetByIdAsync(string id, CancellationToken cancellationToken = default);
/// <summary>
/// Lists all workers.
/// </summary>
Task<IReadOnlyList<WorkerEntity>> ListAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Lists workers by status.
/// </summary>
Task<IReadOnlyList<WorkerEntity>> ListByStatusAsync(string status, CancellationToken cancellationToken = default);
/// <summary>
/// Gets workers for a specific tenant.
/// </summary>
Task<IReadOnlyList<WorkerEntity>> GetByTenantIdAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Registers a new worker or updates existing.
/// </summary>
Task<WorkerEntity> UpsertAsync(WorkerEntity worker, CancellationToken cancellationToken = default);
/// <summary>
/// Updates worker heartbeat.
/// </summary>
Task<bool> HeartbeatAsync(string id, int currentJobs, CancellationToken cancellationToken = default);
/// <summary>
/// Updates worker status.
/// </summary>
Task<bool> SetStatusAsync(string id, string status, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a worker.
/// </summary>
Task<bool> DeleteAsync(string id, CancellationToken cancellationToken = default);
/// <summary>
/// Gets stale workers (no heartbeat in duration).
/// </summary>
Task<IReadOnlyList<WorkerEntity>> GetStaleWorkersAsync(TimeSpan staleDuration, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,244 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for job history operations.
/// </summary>
public sealed class JobHistoryRepository : RepositoryBase<SchedulerDataSource>, IJobHistoryRepository
{
/// <summary>
/// Creates a new job history repository.
/// </summary>
public JobHistoryRepository(SchedulerDataSource dataSource, ILogger<JobHistoryRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<JobHistoryEntity> ArchiveAsync(JobHistoryEntity history, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO scheduler.job_history (
job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
result, reason, worker_id, duration_ms, created_at, completed_at
)
VALUES (
@job_id, @tenant_id, @project_id, @job_type, @status::scheduler.job_status, @attempt, @payload_digest,
@result::jsonb, @reason, @worker_id, @duration_ms, @created_at, @completed_at
)
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(history.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "job_id", history.JobId);
AddParameter(command, "tenant_id", history.TenantId);
AddParameter(command, "project_id", history.ProjectId);
AddParameter(command, "job_type", history.JobType);
AddParameter(command, "status", history.Status.ToString().ToLowerInvariant());
AddParameter(command, "attempt", history.Attempt);
AddParameter(command, "payload_digest", history.PayloadDigest);
AddJsonbParameter(command, "result", history.Result);
AddParameter(command, "reason", history.Reason);
AddParameter(command, "worker_id", history.WorkerId);
AddParameter(command, "duration_ms", history.DurationMs);
AddParameter(command, "created_at", history.CreatedAt);
AddParameter(command, "completed_at", history.CompletedAt);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapJobHistory(reader);
}
/// <inheritdoc />
public async Task<IReadOnlyList<JobHistoryEntity>> GetByJobIdAsync(Guid jobId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
FROM scheduler.job_history
WHERE job_id = @job_id
ORDER BY archived_at DESC
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "job_id", jobId);
var results = new List<JobHistoryEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapJobHistory(reader));
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<JobHistoryEntity>> ListAsync(
string tenantId,
int limit = 100,
int offset = 0,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
FROM scheduler.job_history
WHERE tenant_id = @tenant_id
ORDER BY completed_at DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
MapJobHistory,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<JobHistoryEntity>> ListByJobTypeAsync(
string tenantId,
string jobType,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
FROM scheduler.job_history
WHERE tenant_id = @tenant_id AND job_type = @job_type
ORDER BY completed_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "job_type", jobType);
AddParameter(cmd, "limit", limit);
},
MapJobHistory,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<JobHistoryEntity>> ListByStatusAsync(
string tenantId,
JobStatus status,
int limit = 100,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
FROM scheduler.job_history
WHERE tenant_id = @tenant_id AND status = @status::scheduler.job_status
ORDER BY completed_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "status", status.ToString().ToLowerInvariant());
AddParameter(cmd, "limit", limit);
},
MapJobHistory,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<JobHistoryEntity>> ListByTimeRangeAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
int limit = 1000,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, job_id, tenant_id, project_id, job_type, status, attempt, payload_digest,
result, reason, worker_id, duration_ms, created_at, completed_at, archived_at
FROM scheduler.job_history
WHERE tenant_id = @tenant_id AND completed_at >= @from AND completed_at < @to
ORDER BY completed_at DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "from", from);
AddParameter(cmd, "to", to);
AddParameter(cmd, "limit", limit);
},
MapJobHistory,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM scheduler.job_history WHERE archived_at < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static JobHistoryEntity MapJobHistory(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(reader.GetOrdinal("id")),
JobId = reader.GetGuid(reader.GetOrdinal("job_id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
ProjectId = GetNullableString(reader, reader.GetOrdinal("project_id")),
JobType = reader.GetString(reader.GetOrdinal("job_type")),
Status = ParseJobStatus(reader.GetString(reader.GetOrdinal("status"))),
Attempt = reader.GetInt32(reader.GetOrdinal("attempt")),
PayloadDigest = reader.GetString(reader.GetOrdinal("payload_digest")),
Result = GetNullableString(reader, reader.GetOrdinal("result")),
Reason = GetNullableString(reader, reader.GetOrdinal("reason")),
WorkerId = GetNullableString(reader, reader.GetOrdinal("worker_id")),
DurationMs = reader.IsDBNull(reader.GetOrdinal("duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("duration_ms")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
CompletedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("completed_at")),
ArchivedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("archived_at"))
};
private static JobStatus ParseJobStatus(string status) => status switch
{
"pending" => JobStatus.Pending,
"scheduled" => JobStatus.Scheduled,
"leased" => JobStatus.Leased,
"running" => JobStatus.Running,
"succeeded" => JobStatus.Succeeded,
"failed" => JobStatus.Failed,
"canceled" => JobStatus.Canceled,
"timed_out" => JobStatus.TimedOut,
_ => throw new ArgumentException($"Unknown job status: {status}", nameof(status))
};
}

View File

@@ -0,0 +1,178 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for metrics operations.
/// </summary>
public sealed class MetricsRepository : RepositoryBase<SchedulerDataSource>, IMetricsRepository
{
/// <summary>
/// Creates a new metrics repository.
/// </summary>
public MetricsRepository(SchedulerDataSource dataSource, ILogger<MetricsRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<MetricsEntity> UpsertAsync(MetricsEntity metrics, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO scheduler.metrics (
tenant_id, job_type, period_start, period_end, jobs_created, jobs_completed,
jobs_failed, jobs_timed_out, avg_duration_ms, p50_duration_ms, p95_duration_ms, p99_duration_ms
)
VALUES (
@tenant_id, @job_type, @period_start, @period_end, @jobs_created, @jobs_completed,
@jobs_failed, @jobs_timed_out, @avg_duration_ms, @p50_duration_ms, @p95_duration_ms, @p99_duration_ms
)
ON CONFLICT (tenant_id, job_type, period_start) DO UPDATE SET
period_end = EXCLUDED.period_end,
jobs_created = EXCLUDED.jobs_created,
jobs_completed = EXCLUDED.jobs_completed,
jobs_failed = EXCLUDED.jobs_failed,
jobs_timed_out = EXCLUDED.jobs_timed_out,
avg_duration_ms = EXCLUDED.avg_duration_ms,
p50_duration_ms = EXCLUDED.p50_duration_ms,
p95_duration_ms = EXCLUDED.p95_duration_ms,
p99_duration_ms = EXCLUDED.p99_duration_ms
RETURNING *
""";
await using var connection = await DataSource.OpenConnectionAsync(metrics.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", metrics.TenantId);
AddParameter(command, "job_type", metrics.JobType);
AddParameter(command, "period_start", metrics.PeriodStart);
AddParameter(command, "period_end", metrics.PeriodEnd);
AddParameter(command, "jobs_created", metrics.JobsCreated);
AddParameter(command, "jobs_completed", metrics.JobsCompleted);
AddParameter(command, "jobs_failed", metrics.JobsFailed);
AddParameter(command, "jobs_timed_out", metrics.JobsTimedOut);
AddParameter(command, "avg_duration_ms", metrics.AvgDurationMs);
AddParameter(command, "p50_duration_ms", metrics.P50DurationMs);
AddParameter(command, "p95_duration_ms", metrics.P95DurationMs);
AddParameter(command, "p99_duration_ms", metrics.P99DurationMs);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapMetrics(reader);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MetricsEntity>> GetAsync(
string tenantId,
string jobType,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, job_type, period_start, period_end, jobs_created, jobs_completed,
jobs_failed, jobs_timed_out, avg_duration_ms, p50_duration_ms, p95_duration_ms, p99_duration_ms, created_at
FROM scheduler.metrics
WHERE tenant_id = @tenant_id AND job_type = @job_type
AND period_start >= @from AND period_start < @to
ORDER BY period_start
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "job_type", jobType);
AddParameter(cmd, "from", from);
AddParameter(cmd, "to", to);
},
MapMetrics,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MetricsEntity>> GetByTenantAsync(
string tenantId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, job_type, period_start, period_end, jobs_created, jobs_completed,
jobs_failed, jobs_timed_out, avg_duration_ms, p50_duration_ms, p95_duration_ms, p99_duration_ms, created_at
FROM scheduler.metrics
WHERE tenant_id = @tenant_id AND period_start >= @from AND period_start < @to
ORDER BY period_start, job_type
""";
return await QueryAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "from", from);
AddParameter(cmd, "to", to);
},
MapMetrics,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MetricsEntity>> GetLatestAsync(
string tenantId,
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT DISTINCT ON (job_type) id, tenant_id, job_type, period_start, period_end,
jobs_created, jobs_completed, jobs_failed, jobs_timed_out,
avg_duration_ms, p50_duration_ms, p95_duration_ms, p99_duration_ms, created_at
FROM scheduler.metrics
WHERE tenant_id = @tenant_id
ORDER BY job_type, period_start DESC
""";
return await QueryAsync(
tenantId,
sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapMetrics,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<int> DeleteOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM scheduler.metrics WHERE period_end < @cutoff";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "cutoff", cutoff);
return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static MetricsEntity MapMetrics(NpgsqlDataReader reader) => new()
{
Id = reader.GetInt64(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
JobType = reader.GetString(reader.GetOrdinal("job_type")),
PeriodStart = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("period_start")),
PeriodEnd = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("period_end")),
JobsCreated = reader.GetInt64(reader.GetOrdinal("jobs_created")),
JobsCompleted = reader.GetInt64(reader.GetOrdinal("jobs_completed")),
JobsFailed = reader.GetInt64(reader.GetOrdinal("jobs_failed")),
JobsTimedOut = reader.GetInt64(reader.GetOrdinal("jobs_timed_out")),
AvgDurationMs = reader.IsDBNull(reader.GetOrdinal("avg_duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("avg_duration_ms")),
P50DurationMs = reader.IsDBNull(reader.GetOrdinal("p50_duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("p50_duration_ms")),
P95DurationMs = reader.IsDBNull(reader.GetOrdinal("p95_duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("p95_duration_ms")),
P99DurationMs = reader.IsDBNull(reader.GetOrdinal("p99_duration_ms")) ? null : reader.GetInt64(reader.GetOrdinal("p99_duration_ms")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at"))
};
}

View File

@@ -0,0 +1,301 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for trigger operations.
/// </summary>
public sealed class TriggerRepository : RepositoryBase<SchedulerDataSource>, ITriggerRepository
{
/// <summary>
/// Creates a new trigger repository.
/// </summary>
public TriggerRepository(SchedulerDataSource dataSource, ILogger<TriggerRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<TriggerEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
enabled, next_fire_at, last_fire_at, last_job_id, fire_count, misfire_count,
metadata, created_at, updated_at, created_by
FROM scheduler.triggers
WHERE tenant_id = @tenant_id AND id = @id
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
MapTrigger,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<TriggerEntity?> GetByNameAsync(string tenantId, string name, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
enabled, next_fire_at, last_fire_at, last_job_id, fire_count, misfire_count,
metadata, created_at, updated_at, created_by
FROM scheduler.triggers
WHERE tenant_id = @tenant_id AND name = @name
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "name", name);
},
MapTrigger,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TriggerEntity>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
enabled, next_fire_at, last_fire_at, last_job_id, fire_count, misfire_count,
metadata, created_at, updated_at, created_by
FROM scheduler.triggers
WHERE tenant_id = @tenant_id
ORDER BY name
""";
return await QueryAsync(
tenantId,
sql,
cmd => AddParameter(cmd, "tenant_id", tenantId),
MapTrigger,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TriggerEntity>> GetDueTriggersAsync(int limit = 100, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
enabled, next_fire_at, last_fire_at, last_job_id, fire_count, misfire_count,
metadata, created_at, updated_at, created_by
FROM scheduler.triggers
WHERE enabled = TRUE AND next_fire_at <= NOW()
ORDER BY next_fire_at
LIMIT @limit
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "limit", limit);
var results = new List<TriggerEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapTrigger(reader));
}
return results;
}
/// <inheritdoc />
public async Task<TriggerEntity> CreateAsync(TriggerEntity trigger, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO scheduler.triggers (
id, tenant_id, name, description, job_type, job_payload, cron_expression, timezone,
enabled, next_fire_at, metadata, created_by
)
VALUES (
@id, @tenant_id, @name, @description, @job_type, @job_payload::jsonb, @cron_expression, @timezone,
@enabled, @next_fire_at, @metadata::jsonb, @created_by
)
RETURNING *
""";
var id = trigger.Id == Guid.Empty ? Guid.NewGuid() : trigger.Id;
await using var connection = await DataSource.OpenConnectionAsync(trigger.TenantId, "writer", cancellationToken)
.ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "tenant_id", trigger.TenantId);
AddParameter(command, "name", trigger.Name);
AddParameter(command, "description", trigger.Description);
AddParameter(command, "job_type", trigger.JobType);
AddJsonbParameter(command, "job_payload", trigger.JobPayload);
AddParameter(command, "cron_expression", trigger.CronExpression);
AddParameter(command, "timezone", trigger.Timezone);
AddParameter(command, "enabled", trigger.Enabled);
AddParameter(command, "next_fire_at", trigger.NextFireAt);
AddJsonbParameter(command, "metadata", trigger.Metadata);
AddParameter(command, "created_by", trigger.CreatedBy);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapTrigger(reader);
}
/// <inheritdoc />
public async Task<bool> UpdateAsync(TriggerEntity trigger, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE scheduler.triggers
SET name = @name,
description = @description,
job_type = @job_type,
job_payload = @job_payload::jsonb,
cron_expression = @cron_expression,
timezone = @timezone,
enabled = @enabled,
next_fire_at = @next_fire_at,
metadata = @metadata::jsonb
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
trigger.TenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", trigger.TenantId);
AddParameter(cmd, "id", trigger.Id);
AddParameter(cmd, "name", trigger.Name);
AddParameter(cmd, "description", trigger.Description);
AddParameter(cmd, "job_type", trigger.JobType);
AddJsonbParameter(cmd, "job_payload", trigger.JobPayload);
AddParameter(cmd, "cron_expression", trigger.CronExpression);
AddParameter(cmd, "timezone", trigger.Timezone);
AddParameter(cmd, "enabled", trigger.Enabled);
AddParameter(cmd, "next_fire_at", trigger.NextFireAt);
AddJsonbParameter(cmd, "metadata", trigger.Metadata);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> RecordFireAsync(string tenantId, Guid triggerId, Guid jobId, DateTimeOffset? nextFireAt, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE scheduler.triggers
SET last_fire_at = NOW(),
last_job_id = @job_id,
next_fire_at = @next_fire_at,
fire_count = fire_count + 1
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", triggerId);
AddParameter(cmd, "job_id", jobId);
AddParameter(cmd, "next_fire_at", nextFireAt);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> RecordMisfireAsync(string tenantId, Guid triggerId, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE scheduler.triggers
SET misfire_count = misfire_count + 1
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", triggerId);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetEnabledAsync(string tenantId, Guid id, bool enabled, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE scheduler.triggers
SET enabled = @enabled
WHERE tenant_id = @tenant_id AND id = @id
""";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
AddParameter(cmd, "enabled", enabled);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM scheduler.triggers WHERE tenant_id = @tenant_id AND id = @id";
var rows = await ExecuteAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "id", id);
},
cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static TriggerEntity MapTrigger(NpgsqlDataReader reader) => new()
{
Id = reader.GetGuid(reader.GetOrdinal("id")),
TenantId = reader.GetString(reader.GetOrdinal("tenant_id")),
Name = reader.GetString(reader.GetOrdinal("name")),
Description = GetNullableString(reader, reader.GetOrdinal("description")),
JobType = reader.GetString(reader.GetOrdinal("job_type")),
JobPayload = reader.GetString(reader.GetOrdinal("job_payload")),
CronExpression = reader.GetString(reader.GetOrdinal("cron_expression")),
Timezone = reader.GetString(reader.GetOrdinal("timezone")),
Enabled = reader.GetBoolean(reader.GetOrdinal("enabled")),
NextFireAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("next_fire_at")),
LastFireAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("last_fire_at")),
LastJobId = GetNullableGuid(reader, reader.GetOrdinal("last_job_id")),
FireCount = reader.GetInt64(reader.GetOrdinal("fire_count")),
MisfireCount = reader.GetInt32(reader.GetOrdinal("misfire_count")),
Metadata = reader.GetString(reader.GetOrdinal("metadata")),
CreatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("created_at")),
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("updated_at")),
CreatedBy = GetNullableString(reader, reader.GetOrdinal("created_by"))
};
}

View File

@@ -0,0 +1,230 @@
using Microsoft.Extensions.Logging;
using Npgsql;
using StellaOps.Infrastructure.Postgres.Repositories;
using StellaOps.Scheduler.Storage.Postgres.Models;
namespace StellaOps.Scheduler.Storage.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for worker operations.
/// </summary>
public sealed class WorkerRepository : RepositoryBase<SchedulerDataSource>, IWorkerRepository
{
/// <summary>
/// Creates a new worker repository.
/// </summary>
public WorkerRepository(SchedulerDataSource dataSource, ILogger<WorkerRepository> logger)
: base(dataSource, logger)
{
}
/// <inheritdoc />
public async Task<WorkerEntity?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
status, last_heartbeat_at, registered_at, metadata
FROM scheduler.workers
WHERE id = @id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? MapWorker(reader) : null;
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerEntity>> ListAsync(CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
status, last_heartbeat_at, registered_at, metadata
FROM scheduler.workers
ORDER BY registered_at DESC
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
var results = new List<WorkerEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapWorker(reader));
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerEntity>> ListByStatusAsync(string status, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
status, last_heartbeat_at, registered_at, metadata
FROM scheduler.workers
WHERE status = @status
ORDER BY registered_at DESC
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "status", status);
var results = new List<WorkerEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapWorker(reader));
}
return results;
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerEntity>> GetByTenantIdAsync(string tenantId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
status, last_heartbeat_at, registered_at, metadata
FROM scheduler.workers
WHERE tenant_id = @tenant_id OR tenant_id IS NULL
ORDER BY registered_at DESC
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
var results = new List<WorkerEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapWorker(reader));
}
return results;
}
/// <inheritdoc />
public async Task<WorkerEntity> UpsertAsync(WorkerEntity worker, CancellationToken cancellationToken = default)
{
const string sql = """
INSERT INTO scheduler.workers (id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, metadata)
VALUES (@id, @tenant_id, @hostname, @process_id, @job_types, @max_concurrent_jobs, @metadata::jsonb)
ON CONFLICT (id) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
hostname = EXCLUDED.hostname,
process_id = EXCLUDED.process_id,
job_types = EXCLUDED.job_types,
max_concurrent_jobs = EXCLUDED.max_concurrent_jobs,
metadata = EXCLUDED.metadata,
last_heartbeat_at = NOW(),
status = 'active'
RETURNING *
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", worker.Id);
AddParameter(command, "tenant_id", worker.TenantId);
AddParameter(command, "hostname", worker.Hostname);
AddParameter(command, "process_id", worker.ProcessId);
AddTextArrayParameter(command, "job_types", worker.JobTypes);
AddParameter(command, "max_concurrent_jobs", worker.MaxConcurrentJobs);
AddJsonbParameter(command, "metadata", worker.Metadata);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return MapWorker(reader);
}
/// <inheritdoc />
public async Task<bool> HeartbeatAsync(string id, int currentJobs, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE scheduler.workers
SET last_heartbeat_at = NOW(), current_jobs = @current_jobs
WHERE id = @id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "current_jobs", currentJobs);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> SetStatusAsync(string id, string status, CancellationToken cancellationToken = default)
{
const string sql = """
UPDATE scheduler.workers
SET status = @status
WHERE id = @id
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
AddParameter(command, "status", status);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string id, CancellationToken cancellationToken = default)
{
const string sql = "DELETE FROM scheduler.workers WHERE id = @id";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", id);
var rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<IReadOnlyList<WorkerEntity>> GetStaleWorkersAsync(TimeSpan staleDuration, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, hostname, process_id, job_types, max_concurrent_jobs, current_jobs,
status, last_heartbeat_at, registered_at, metadata
FROM scheduler.workers
WHERE status = 'active' AND last_heartbeat_at < NOW() - @stale_duration
ORDER BY last_heartbeat_at
""";
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
await using var command = CreateCommand(sql, connection);
AddParameter(command, "stale_duration", staleDuration);
var results = new List<WorkerEntity>();
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
results.Add(MapWorker(reader));
}
return results;
}
private static WorkerEntity MapWorker(NpgsqlDataReader reader) => new()
{
Id = reader.GetString(reader.GetOrdinal("id")),
TenantId = GetNullableString(reader, reader.GetOrdinal("tenant_id")),
Hostname = reader.GetString(reader.GetOrdinal("hostname")),
ProcessId = reader.IsDBNull(reader.GetOrdinal("process_id")) ? null : reader.GetInt32(reader.GetOrdinal("process_id")),
JobTypes = reader.IsDBNull(reader.GetOrdinal("job_types")) ? [] : reader.GetFieldValue<string[]>(reader.GetOrdinal("job_types")),
MaxConcurrentJobs = reader.GetInt32(reader.GetOrdinal("max_concurrent_jobs")),
CurrentJobs = reader.GetInt32(reader.GetOrdinal("current_jobs")),
Status = reader.GetString(reader.GetOrdinal("status")),
LastHeartbeatAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("last_heartbeat_at")),
RegisteredAt = reader.GetFieldValue<DateTimeOffset>(reader.GetOrdinal("registered_at")),
Metadata = reader.GetString(reader.GetOrdinal("metadata"))
};
}