diff --git a/src/Trax.Effect.Data.Postgres/Migrations/034_scheduler_config.sql b/src/Trax.Effect.Data.Postgres/Migrations/034_scheduler_config.sql
new file mode 100644
index 0000000..99431b3
--- /dev/null
+++ b/src/Trax.Effect.Data.Postgres/Migrations/034_scheduler_config.sql
@@ -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()
+);
diff --git a/src/Trax.Effect.Data.Sqlite/Migrations/002_scheduler_config.sql b/src/Trax.Effect.Data.Sqlite/Migrations/002_scheduler_config.sql
new file mode 100644
index 0000000..4ee455a
--- /dev/null
+++ b/src/Trax.Effect.Data.Sqlite/Migrations/002_scheduler_config.sql
@@ -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'))
+);
diff --git a/src/Trax.Effect.Data/Models/SchedulerConfig/PersistentSchedulerConfig.cs b/src/Trax.Effect.Data/Models/SchedulerConfig/PersistentSchedulerConfig.cs
new file mode 100644
index 0000000..e664c2d
--- /dev/null
+++ b/src/Trax.Effect.Data/Models/SchedulerConfig/PersistentSchedulerConfig.cs
@@ -0,0 +1,19 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace Trax.Effect.Data.Models.SchedulerConfig;
+
+///
+/// EF Core configuration for the singleton scheduler_config row.
+///
+public class PersistentSchedulerConfig : Effect.Models.SchedulerConfig.SchedulerConfig
+{
+ internal static void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("scheduler_config", "trax");
+ entity.HasKey(e => e.Id);
+ // No ValueGeneratedOnAdd: callers (and the migration) supply the singleton id.
+ });
+ }
+}
diff --git a/src/Trax.Effect.Data/Services/DataContext/DataContext.cs b/src/Trax.Effect.Data/Services/DataContext/DataContext.cs
index 21cc44f..9255e3d 100644
--- a/src/Trax.Effect.Data/Services/DataContext/DataContext.cs
+++ b/src/Trax.Effect.Data/Services/DataContext/DataContext.cs
@@ -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;
@@ -96,6 +97,8 @@ public class DataContext(DbContextOptions options)
public DbSet BackgroundJobs { get; set; }
+ public DbSet SchedulerConfigs { get; set; }
+
#endregion
///
diff --git a/src/Trax.Effect.Data/Services/DataContext/IDataContext.cs b/src/Trax.Effect.Data/Services/DataContext/IDataContext.cs
index ef33fdf..35fd362 100644
--- a/src/Trax.Effect.Data/Services/DataContext/IDataContext.cs
+++ b/src/Trax.Effect.Data/Services/DataContext/IDataContext.cs
@@ -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;
@@ -90,6 +91,13 @@ public interface IDataContext : IEffectProvider, IAsyncDisposable
DbSet BackgroundJobs { get; }
+ ///
+ /// Singleton-row table holding persisted scheduler runtime settings (the
+ /// dashboard-editable subset of SchedulerConfiguration). Always contains
+ /// zero or one rows.
+ ///
+ DbSet SchedulerConfigs { get; }
+
#endregion
///
diff --git a/src/Trax.Effect/Models/SchedulerConfig/SchedulerConfig.cs b/src/Trax.Effect/Models/SchedulerConfig/SchedulerConfig.cs
new file mode 100644
index 0000000..ccd4606
--- /dev/null
+++ b/src/Trax.Effect/Models/SchedulerConfig/SchedulerConfig.cs
@@ -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;
+
+///
+/// Persisted scheduler runtime settings. Holds the dashboard-editable subset of
+/// SchedulerConfiguration + LocalWorkerOptions + MetadataCleanupConfiguration
+/// so a React or Blazor dashboard can read and update them, and so settings survive
+/// app restarts.
+///
+///
+/// This is a singleton table: exactly one row exists, with always 1.
+/// A CHECK constraint (set in the migration) prevents inserts of any other id.
+///
+public class SchedulerConfig : IModel
+{
+ /// The singleton row id. Always 1.
+ 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() { }
+}
diff --git a/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/InMemoryProviderTests.cs b/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/InMemoryProviderTests.cs
index b501e2f..c0f7960 100644
--- a/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/InMemoryProviderTests.cs
+++ b/tests/Trax.Effect.Tests.Data.InMemory.Integration/IntegrationTests/InMemoryProviderTests.cs
@@ -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();
+ 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();
+ 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]
diff --git a/tests/Trax.Effect.Tests.Data.Sqlite.Integration/IntegrationTests/SqliteMigrationTests.cs b/tests/Trax.Effect.Tests.Data.Sqlite.Integration/IntegrationTests/SqliteMigrationTests.cs
index 6b9213f..3e36e11 100644
--- a/tests/Trax.Effect.Tests.Data.Sqlite.Integration/IntegrationTests/SqliteMigrationTests.cs
+++ b/tests/Trax.Effect.Tests.Data.Sqlite.Integration/IntegrationTests/SqliteMigrationTests.cs
@@ -15,6 +15,7 @@ public class SqliteMigrationTests
"manifest",
"manifest_group",
"metadata",
+ "scheduler_config",
"work_queue",
];
diff --git a/tests/Trax.Effect.Tests.Data.Sqlite.Integration/IntegrationTests/SqliteProviderTests.cs b/tests/Trax.Effect.Tests.Data.Sqlite.Integration/IntegrationTests/SqliteProviderTests.cs
index 3ed1be0..0ebc20a 100644
--- a/tests/Trax.Effect.Tests.Data.Sqlite.Integration/IntegrationTests/SqliteProviderTests.cs
+++ b/tests/Trax.Effect.Tests.Data.Sqlite.Integration/IntegrationTests/SqliteProviderTests.cs
@@ -458,6 +458,133 @@ public async Task Track_Log_PersistsCorrectly()
#endregion
+ #region Entity Type Tests - SchedulerConfig
+
+ [Test]
+ public async Task SchedulerConfig_RoundTrip_PersistsAllColumns()
+ {
+ var factory = Scope.ServiceProvider.GetRequiredService();
+ var context = (IDataContext)factory.Create();
+ // Singleton-row table: ensure no leftover row from a prior test in the fixture.
+ await context.SchedulerConfigs.ExecuteDeleteAsync();
+
+ // SchedulerConfig is a singleton row (id always = 1) and the model initialiser
+ // sets Id = 1 in its ctor. The shared OperationsService uses DbSet.Add directly
+ // for inserts because IDataContext.Track infers Added/Modified from `Id > 0`,
+ // which would misclassify the singleton as an update on first persist.
+ 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.JobDispatcherEnabled.Should().BeFalse();
+ found.ManifestManagerPollingInterval.Should().Be(TimeSpan.FromSeconds(15));
+ found.JobDispatcherPollingInterval.Should().Be(TimeSpan.FromSeconds(20));
+ found.MaxActiveJobs.Should().Be(50);
+ found.DefaultMaxRetries.Should().Be(7);
+ found.DefaultRetryDelay.Should().Be(TimeSpan.FromMinutes(10));
+ found.RetryBackoffMultiplier.Should().Be(3.5);
+ found.MaxRetryDelay.Should().Be(TimeSpan.FromHours(2));
+ found.DefaultJobTimeout.Should().Be(TimeSpan.FromMinutes(45));
+ found.StalePendingTimeout.Should().Be(TimeSpan.FromMinutes(30));
+ found.RecoverStuckJobsOnStartup.Should().BeFalse();
+ found.DeadLetterRetentionPeriod.Should().Be(TimeSpan.FromDays(60));
+ found.AutoPurgeDeadLetters.Should().BeFalse();
+ 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()
+ {
+ // Defaults: MaxActiveJobs is set to 10 by the model initialiser; the rest of the
+ // nullable columns (LocalWorkerCount, MetadataCleanup*) default to null. Verify
+ // each round-trips correctly when explicitly null.
+ var factory = Scope.ServiceProvider.GetRequiredService();
+ var context = (IDataContext)factory.Create();
+ await context.SchedulerConfigs.ExecuteDeleteAsync();
+
+ 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();
+ }
+
+ [Test]
+ public async Task SchedulerConfig_UpdateExistingRow_UpdatesNotInserts()
+ {
+ var factory = Scope.ServiceProvider.GetRequiredService();
+ var context = (IDataContext)factory.Create();
+ await context.SchedulerConfigs.ExecuteDeleteAsync();
+
+ // Insert
+ context.SchedulerConfigs.Add(
+ new Effect.Models.SchedulerConfig.SchedulerConfig
+ {
+ DefaultMaxRetries = 5,
+ UpdatedAt = DateTime.UtcNow,
+ }
+ );
+ await context.SaveChanges(CancellationToken.None);
+ context.Reset();
+
+ // Update
+ var existing = await context.SchedulerConfigs.FirstAsync();
+ existing.DefaultMaxRetries = 11;
+ await context.SaveChanges(CancellationToken.None);
+ context.Reset();
+
+ // Still exactly one row, with the updated value.
+ var rows = await context.SchedulerConfigs.ToListAsync();
+ rows.Should().ContainSingle();
+ rows[0].DefaultMaxRetries.Should().Be(11);
+ }
+
+ #endregion
+
#region Entity Type Tests - ManifestGroup
[Test]
diff --git a/tests/Trax.Effect.Tests.Integration/UnitTests/Models/ModelToStringTests.cs b/tests/Trax.Effect.Tests.Integration/UnitTests/Models/ModelToStringTests.cs
index 2fc0113..be90a5e 100644
--- a/tests/Trax.Effect.Tests.Integration/UnitTests/Models/ModelToStringTests.cs
+++ b/tests/Trax.Effect.Tests.Integration/UnitTests/Models/ModelToStringTests.cs
@@ -8,6 +8,7 @@
using Trax.Effect.Models.Manifest;
using Trax.Effect.Models.Manifest.DTOs;
using Trax.Effect.Models.ManifestGroup;
+using Trax.Effect.Models.SchedulerConfig;
using Trax.Effect.Models.WorkQueue;
using Trax.Effect.Models.WorkQueue.DTOs;
@@ -160,6 +161,62 @@ public void BackgroundJob_Create_AndProperties()
job.ToString().Should().NotBeNullOrEmpty().And.Contain("99");
}
+ [Test]
+ public void SchedulerConfig_DefaultsAndPropertiesAndToString()
+ {
+ // Bare ctor + every property accessor exercised so coverage measurement on the
+ // POCO model (which is otherwise only used through its persisted representation
+ // in Trax.Scheduler) reflects what's actually shipped.
+ var cfg = new SchedulerConfig();
+
+ cfg.Id.Should().Be(SchedulerConfig.SingletonId);
+ cfg.ManifestManagerEnabled.Should().BeTrue();
+ cfg.JobDispatcherEnabled.Should().BeTrue();
+ cfg.ManifestManagerPollingInterval.Should().Be(TimeSpan.FromSeconds(5));
+ cfg.JobDispatcherPollingInterval.Should().Be(TimeSpan.FromSeconds(2));
+ cfg.MaxActiveJobs.Should().Be(10);
+ cfg.DefaultMaxRetries.Should().Be(3);
+ cfg.DefaultRetryDelay.Should().Be(TimeSpan.FromMinutes(5));
+ cfg.RetryBackoffMultiplier.Should().Be(2.0);
+ cfg.MaxRetryDelay.Should().Be(TimeSpan.FromHours(1));
+ cfg.DefaultJobTimeout.Should().Be(TimeSpan.FromMinutes(20));
+ cfg.StalePendingTimeout.Should().Be(TimeSpan.FromMinutes(20));
+ cfg.RecoverStuckJobsOnStartup.Should().BeTrue();
+ cfg.DeadLetterRetentionPeriod.Should().Be(TimeSpan.FromDays(30));
+ cfg.AutoPurgeDeadLetters.Should().BeTrue();
+ cfg.LocalWorkerCount.Should().BeNull();
+ cfg.MetadataCleanupInterval.Should().BeNull();
+ cfg.MetadataCleanupRetention.Should().BeNull();
+ cfg.UpdatedAt.Should().Be(default);
+
+ var when = DateTime.UtcNow;
+ cfg.Id = 1;
+ cfg.ManifestManagerEnabled = false;
+ cfg.JobDispatcherEnabled = false;
+ cfg.ManifestManagerPollingInterval = TimeSpan.FromSeconds(15);
+ cfg.JobDispatcherPollingInterval = TimeSpan.FromSeconds(20);
+ cfg.MaxActiveJobs = 50;
+ cfg.DefaultMaxRetries = 7;
+ cfg.DefaultRetryDelay = TimeSpan.FromMinutes(10);
+ cfg.RetryBackoffMultiplier = 3.5;
+ cfg.MaxRetryDelay = TimeSpan.FromHours(2);
+ cfg.DefaultJobTimeout = TimeSpan.FromMinutes(45);
+ cfg.StalePendingTimeout = TimeSpan.FromMinutes(30);
+ cfg.RecoverStuckJobsOnStartup = false;
+ cfg.DeadLetterRetentionPeriod = TimeSpan.FromDays(60);
+ cfg.AutoPurgeDeadLetters = false;
+ cfg.LocalWorkerCount = 8;
+ cfg.MetadataCleanupInterval = TimeSpan.FromMinutes(7);
+ cfg.MetadataCleanupRetention = TimeSpan.FromHours(3);
+ cfg.UpdatedAt = when;
+
+ cfg.MaxActiveJobs.Should().Be(50);
+ cfg.LocalWorkerCount.Should().Be(8);
+ cfg.UpdatedAt.Should().Be(when);
+
+ cfg.ToString().Should().NotBeNullOrEmpty().And.Contain("50");
+ }
+
private sealed record Sample : IManifestProperties
{
public string Value { get; init; } = "";