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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/Trax.Effect.Data.Postgres/Migrations/034_scheduler_config.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Singleton-row table holding persisted scheduler runtime settings.
-- The CHECK constraint enforces "exactly one row" by limiting id to 1.
CREATE TABLE IF NOT EXISTS trax.scheduler_config (
id bigint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
manifest_manager_enabled boolean NOT NULL DEFAULT true,
job_dispatcher_enabled boolean NOT NULL DEFAULT true,
manifest_manager_polling_interval interval NOT NULL DEFAULT '5 seconds',
job_dispatcher_polling_interval interval NOT NULL DEFAULT '2 seconds',
max_active_jobs integer,
default_max_retries integer NOT NULL DEFAULT 3,
default_retry_delay interval NOT NULL DEFAULT '5 minutes',
retry_backoff_multiplier double precision NOT NULL DEFAULT 2.0,
max_retry_delay interval NOT NULL DEFAULT '1 hour',
default_job_timeout interval NOT NULL DEFAULT '20 minutes',
stale_pending_timeout interval NOT NULL DEFAULT '20 minutes',
recover_stuck_jobs_on_startup boolean NOT NULL DEFAULT true,
dead_letter_retention_period interval NOT NULL DEFAULT '30 days',
auto_purge_dead_letters boolean NOT NULL DEFAULT true,
local_worker_count integer,
metadata_cleanup_interval interval,
metadata_cleanup_retention interval,
updated_at timestamptz NOT NULL DEFAULT now()
);
21 changes: 21 additions & 0 deletions src/Trax.Effect.Data.Sqlite/Migrations/002_scheduler_config.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS scheduler_config (
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
manifest_manager_enabled INTEGER NOT NULL DEFAULT 1,
job_dispatcher_enabled INTEGER NOT NULL DEFAULT 1,
manifest_manager_polling_interval TEXT NOT NULL DEFAULT '00:00:05',
job_dispatcher_polling_interval TEXT NOT NULL DEFAULT '00:00:02',
max_active_jobs INTEGER,
default_max_retries INTEGER NOT NULL DEFAULT 3,
default_retry_delay TEXT NOT NULL DEFAULT '00:05:00',
retry_backoff_multiplier REAL NOT NULL DEFAULT 2.0,
max_retry_delay TEXT NOT NULL DEFAULT '01:00:00',
default_job_timeout TEXT NOT NULL DEFAULT '00:20:00',
stale_pending_timeout TEXT NOT NULL DEFAULT '00:20:00',
recover_stuck_jobs_on_startup INTEGER NOT NULL DEFAULT 1,
dead_letter_retention_period TEXT NOT NULL DEFAULT '30.00:00:00',
auto_purge_dead_letters INTEGER NOT NULL DEFAULT 1,
local_worker_count INTEGER,
metadata_cleanup_interval TEXT,
metadata_cleanup_retention TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;

namespace Trax.Effect.Data.Models.SchedulerConfig;

/// <summary>
/// EF Core configuration for the singleton <c>scheduler_config</c> row.
/// </summary>
public class PersistentSchedulerConfig : Effect.Models.SchedulerConfig.SchedulerConfig
{
internal static void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Effect.Models.SchedulerConfig.SchedulerConfig>(entity =>
{
entity.ToTable("scheduler_config", "trax");
entity.HasKey(e => e.Id);
// No ValueGeneratedOnAdd: callers (and the migration) supply the singleton id.
});
}
}
3 changes: 3 additions & 0 deletions src/Trax.Effect.Data/Services/DataContext/DataContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Trax.Effect.Models.Manifest;
using Trax.Effect.Models.ManifestGroup;
using Trax.Effect.Models.Metadata;
using Trax.Effect.Models.SchedulerConfig;
using Trax.Effect.Models.WorkQueue;

namespace Trax.Effect.Data.Services.DataContext;
Expand Down Expand Up @@ -96,6 +97,8 @@ public class DataContext<TDbContext>(DbContextOptions<TDbContext> options)

public DbSet<BackgroundJob> BackgroundJobs { get; set; }

public DbSet<SchedulerConfig> SchedulerConfigs { get; set; }

#endregion

/// <summary>
Expand Down
8 changes: 8 additions & 0 deletions src/Trax.Effect.Data/Services/DataContext/IDataContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Trax.Effect.Models.Manifest;
using Trax.Effect.Models.ManifestGroup;
using Trax.Effect.Models.Metadata;
using Trax.Effect.Models.SchedulerConfig;
using Trax.Effect.Models.WorkQueue;
using Trax.Effect.Services.EffectProvider;

Expand Down Expand Up @@ -90,6 +91,13 @@ public interface IDataContext : IEffectProvider, IAsyncDisposable

DbSet<BackgroundJob> BackgroundJobs { get; }

/// <summary>
/// Singleton-row table holding persisted scheduler runtime settings (the
/// dashboard-editable subset of <c>SchedulerConfiguration</c>). Always contains
/// zero or one rows.
/// </summary>
DbSet<SchedulerConfig> SchedulerConfigs { get; }

#endregion

/// <summary>
Expand Down
85 changes: 85 additions & 0 deletions src/Trax.Effect/Models/SchedulerConfig/SchedulerConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using System.Text.Json.Serialization;
using Trax.Effect.Configuration.TraxEffectConfiguration;

namespace Trax.Effect.Models.SchedulerConfig;

/// <summary>
/// Persisted scheduler runtime settings. Holds the dashboard-editable subset of
/// <c>SchedulerConfiguration</c> + <c>LocalWorkerOptions</c> + <c>MetadataCleanupConfiguration</c>
/// so a React or Blazor dashboard can read and update them, and so settings survive
/// app restarts.
/// </summary>
/// <remarks>
/// This is a singleton table: exactly one row exists, with <see cref="Id"/> always 1.
/// A CHECK constraint (set in the migration) prevents inserts of any other id.
/// </remarks>
public class SchedulerConfig : IModel
{
/// <summary>The singleton row id. Always 1.</summary>
public const long SingletonId = 1L;

[Column("id")]
public long Id { get; set; } = SingletonId;

[Column("manifest_manager_enabled")]
public bool ManifestManagerEnabled { get; set; } = true;

[Column("job_dispatcher_enabled")]
public bool JobDispatcherEnabled { get; set; } = true;

[Column("manifest_manager_polling_interval")]
public TimeSpan ManifestManagerPollingInterval { get; set; } = TimeSpan.FromSeconds(5);

[Column("job_dispatcher_polling_interval")]
public TimeSpan JobDispatcherPollingInterval { get; set; } = TimeSpan.FromSeconds(2);

[Column("max_active_jobs")]
public int? MaxActiveJobs { get; set; } = 10;

[Column("default_max_retries")]
public int DefaultMaxRetries { get; set; } = 3;

[Column("default_retry_delay")]
public TimeSpan DefaultRetryDelay { get; set; } = TimeSpan.FromMinutes(5);

[Column("retry_backoff_multiplier")]
public double RetryBackoffMultiplier { get; set; } = 2.0;

[Column("max_retry_delay")]
public TimeSpan MaxRetryDelay { get; set; } = TimeSpan.FromHours(1);

[Column("default_job_timeout")]
public TimeSpan DefaultJobTimeout { get; set; } = TimeSpan.FromMinutes(20);

[Column("stale_pending_timeout")]
public TimeSpan StalePendingTimeout { get; set; } = TimeSpan.FromMinutes(20);

[Column("recover_stuck_jobs_on_startup")]
public bool RecoverStuckJobsOnStartup { get; set; } = true;

[Column("dead_letter_retention_period")]
public TimeSpan DeadLetterRetentionPeriod { get; set; } = TimeSpan.FromDays(30);

[Column("auto_purge_dead_letters")]
public bool AutoPurgeDeadLetters { get; set; } = true;

[Column("local_worker_count")]
public int? LocalWorkerCount { get; set; }

[Column("metadata_cleanup_interval")]
public TimeSpan? MetadataCleanupInterval { get; set; }

[Column("metadata_cleanup_retention")]
public TimeSpan? MetadataCleanupRetention { get; set; }

[Column("updated_at")]
public DateTime UpdatedAt { get; set; }

public override string ToString() =>
JsonSerializer.Serialize(this, TraxEffectConfiguration.StaticSystemJsonSerializerOptions);

[JsonConstructor]
public SchedulerConfig() { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,95 @@ public async Task MultipleEntities_CanBeTrackedAndSaved()

#endregion

#region Entity Type Tests - SchedulerConfig

[Test]
public async Task SchedulerConfig_RoundTrip_PersistsAllColumns()
{
var factory = Scope.ServiceProvider.GetRequiredService<IDataContextProviderFactory>();
var context = (IDataContext)factory.Create();

// InMemory provider doesn't support ExecuteDeleteAsync; remove rows individually.
foreach (var existing in await context.SchedulerConfigs.ToListAsync())
((Microsoft.EntityFrameworkCore.DbContext)context).Remove(existing);
await context.SaveChanges(CancellationToken.None);

// Singleton row with id = 1. Use DbSet.Add directly — see the comment in
// OperationsService.PersistAsync for why IDataContext.Track misclassifies this.
var row = new Effect.Models.SchedulerConfig.SchedulerConfig
{
ManifestManagerEnabled = false,
JobDispatcherEnabled = false,
ManifestManagerPollingInterval = TimeSpan.FromSeconds(15),
JobDispatcherPollingInterval = TimeSpan.FromSeconds(20),
MaxActiveJobs = 50,
DefaultMaxRetries = 7,
DefaultRetryDelay = TimeSpan.FromMinutes(10),
RetryBackoffMultiplier = 3.5,
MaxRetryDelay = TimeSpan.FromHours(2),
DefaultJobTimeout = TimeSpan.FromMinutes(45),
StalePendingTimeout = TimeSpan.FromMinutes(30),
RecoverStuckJobsOnStartup = false,
DeadLetterRetentionPeriod = TimeSpan.FromDays(60),
AutoPurgeDeadLetters = false,
LocalWorkerCount = 8,
MetadataCleanupInterval = TimeSpan.FromMinutes(7),
MetadataCleanupRetention = TimeSpan.FromHours(3),
UpdatedAt = DateTime.UtcNow,
};

context.SchedulerConfigs.Add(row);
await context.SaveChanges(CancellationToken.None);
context.Reset();

var found = await context.SchedulerConfigs.FirstOrDefaultAsync(x =>
x.Id == Effect.Models.SchedulerConfig.SchedulerConfig.SingletonId
);

found.Should().NotBeNull();
found!.ManifestManagerEnabled.Should().BeFalse();
found.MaxActiveJobs.Should().Be(50);
found.RetryBackoffMultiplier.Should().Be(3.5);
found.DeadLetterRetentionPeriod.Should().Be(TimeSpan.FromDays(60));
found.LocalWorkerCount.Should().Be(8);
found.MetadataCleanupInterval.Should().Be(TimeSpan.FromMinutes(7));
found.MetadataCleanupRetention.Should().Be(TimeSpan.FromHours(3));
}

[Test]
public async Task SchedulerConfig_NullableColumnsRoundTripAsNull()
{
var factory = Scope.ServiceProvider.GetRequiredService<IDataContextProviderFactory>();
var context = (IDataContext)factory.Create();

// InMemory provider doesn't support ExecuteDeleteAsync; remove rows individually.
foreach (var existing in await context.SchedulerConfigs.ToListAsync())
((Microsoft.EntityFrameworkCore.DbContext)context).Remove(existing);
await context.SaveChanges(CancellationToken.None);

var row = new Effect.Models.SchedulerConfig.SchedulerConfig
{
MaxActiveJobs = null,
LocalWorkerCount = null,
MetadataCleanupInterval = null,
MetadataCleanupRetention = null,
UpdatedAt = DateTime.UtcNow,
};

context.SchedulerConfigs.Add(row);
await context.SaveChanges(CancellationToken.None);
context.Reset();

var found = await context.SchedulerConfigs.FirstOrDefaultAsync();
found.Should().NotBeNull();
found!.MaxActiveJobs.Should().BeNull();
found.LocalWorkerCount.Should().BeNull();
found.MetadataCleanupInterval.Should().BeNull();
found.MetadataCleanupRetention.Should().BeNull();
}

#endregion

#region Transaction Tests

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class SqliteMigrationTests
"manifest",
"manifest_group",
"metadata",
"scheduler_config",
"work_queue",
];

Expand Down
Loading
Loading