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; } = "";