Skip to content
Draft
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
29 changes: 29 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Unit Tests

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

jobs:
test:
name: Run Unit Tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

- name: Restore
run: dotnet restore JobMaster.sln

- name: Build
run: dotnet build JobMaster.sln --no-restore --configuration Release

- name: Test
run: dotnet test Tests/JobMaster.UnitTests/JobMaster.UnitTests.csproj --no-build --configuration Release --verbosity normal
57 changes: 57 additions & 0 deletions ChangeLog.internal.md

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,58 @@

---

## [Unreleased]

### Added

- **`DisablePriority` on cluster and standalone selectors** — A priority level can now be disabled for a cluster via `DisablePriority(JobMasterPriority)` (or `ClusterDisablePriority` for standalone clusters) and via JSON config. A disabled priority prevents bucket creation at that level. Any handler class decorated with a `[JobMasterPriority]` that resolves to a disabled priority throws at cluster startup. Static recurring schedules with an explicitly-set disabled priority also throw at startup. Scheduling a job at a disabled priority throws `InvalidOperationException` at the call site.

- **`UseJobMasterClusterApi` — API-only cluster registration** (`JobMaster.Api`) — New `IServiceCollection` extension for registering a cluster in API-only mode (no workers or job handlers). Accepts `Action<IBaseClusterConfigSelector<IClusterConfigSelector>>`, which exposes only connection and identity methods. When all registered clusters go through `UseJobMasterClusterApi`, the first one is automatically promoted to default — since cluster routing in API calls is always explicit by cluster ID, the "default" has no functional meaning in pure API deployments and requiring the user to call `SetAsDefault()` manually would be unnecessary friction.

- **`IBaseClusterConfigSelector<TSelector>` — base interface for connection-only cluster config** — New generic base interface that exposes only connection and identity configuration (`ClusterId`, `SetAsDefault`, and the internal provider-connection methods). `IClusterConfigSelector` now extends `IBaseClusterConfigSelector<IClusterConfigSelector>`, so all existing builder methods still return the full selector type — no changes required at the call site.

- **`ConfigFromJson` — full cluster config from JSON** — The entire cluster setup (cluster settings, agent connections, workers) can now be driven from a JSON file or string via `ConfigFromJson`, removing the need for code-level wiring. Provider-specific connection settings (e.g. NATS auth and TLS) are supported through a `connectionOptions` dictionary — `JobMaster.NatsJetStream` accepts auth keys (`username`, `password`, `token`, `credentialsFile`, `nkey`, `jwt`) and TLS keys (`tlsCertBundleFile`, `tlsCaFile`, `tlsMode`, etc.).

- **`DataRetentionTtl` / `RetainDataForever` in the cluster config selector** — The data retention window for executed jobs, inactive recurring schedules, and JobMaster logs can now be set at startup via `DataRetentionTtl(TimeSpan)` (or `ClusterDataRetentionTtl` for standalone clusters) and through `ConfigFromJson`, instead of only being adjustable by editing the saved cluster configuration directly. `RetainDataForever()` is also available as a named alternative to `DataRetentionTtl(TimeSpan.Zero)`. The minimum accepted positive TTL is 10 minutes (`JobMasterDefaults.MinDataRetentionTtl`) — passing a smaller positive value throws `ArgumentException`. Cleanup runners adapt their check interval automatically to `Clamp(TTL/2, 5 min, 1 hr)`. ⚠️ **Breaking change**: the default `DataRetentionTtl` is now `TimeSpan.Zero` (infinite — no automatic purge), changed from the previous 30 days. Existing deployments that relied on the 30-day automatic cleanup should set `DataRetentionTtl(TimeSpan.FromDays(30))` explicitly.

### Changed

- **`BucketQtyConfig` is now sparse** — `BucketQtyConfig(priority, qty)` stores only the priorities you explicitly configure; unconfigured priorities default to 1 bucket at startup. An explicit `qty = 0` is valid and means no buckets for that priority. If `DisablePriority` is set for a priority and an explicit non-zero qty is also configured for it, startup throws. Previously all five priorities were always seeded to 1 regardless of what was configured, so `BucketQtyConfig(High, 4)` implicitly created 1 bucket for every other priority — now only `High` is affected.

- **Provider extension methods now accept `IBaseClusterConfigSelector<IClusterConfigSelector>`** — `UsePostgresForMaster`, `UseMySqlForMaster`, `UseSqlServerForMaster`, `UseSqlTablePrefixForMaster`, and `DisableAutoProvisionSqlSchema` are now generic (`where T : IBaseClusterConfigSelector<IClusterConfigSelector>`). This is fully backward-compatible — all existing call sites continue to work — and enables calling these methods inside `UseJobMasterClusterApi` without requiring the full cluster selector.

- **`ReadIsolationLevel` removed** — The internal `ReadIsolationLevel` enum and all `ReadIsolationLevel` properties on query criteria classes have been removed. All database reads now use `READ COMMITTED`. SQL Server users are recommended to enable `READ_COMMITTED_SNAPSHOT` (RCSI) on their database to achieve equivalent non-blocking read behaviour without dirty reads.

- **`IClusterConfigSelector` method renames** — The `Cluster` prefix has been removed from cluster configuration methods (`DefaultJobTimeout`, `TransientThreshold`, `DefaultMaxRetryCount`, `MaxMessageByteSize`, `IanaTimeZoneId`, `Mode`). Old names still compile but are marked obsolete and will be removed in a future release. `ClusterId` is unchanged.

- **Coordinator workers no longer take an agent connection — and now must not have one** — A Coordinator only assigns jobs to buckets and manages their lifecycle; it never owns a bucket or claims one for draining, so it has no use for an agent connection of its own. Configuring an `AgentConnectionName` for a Coordinator worker now throws at startup instead of being silently accepted. Every other mode (`Full`, `Execution`, `Drain`) is unaffected and still requires one.

- **`IJobHandler` renamed to `IJobMasterHandler`** — Brings the interface you implement to define a job in line with the rest of the library's naming (`IJobMasterScheduler`, `IJobMasterLogger`, etc.). Existing code implementing `IJobHandler` keeps compiling as-is — it's now marked obsolete and will be removed in a future release, so there's no rush to migrate, but new handlers should implement `IJobMasterHandler` directly.

- **Reserved agent connection names now use a `JMReserved-` prefix** — The framework's internal standalone and fallback-bucket connections are now named `JMReserved-standalone` and `JMReserved-fallback` (previously `standalone-agent-conn` and `master-fallback-agent-conn`). Attempting to name your own agent connection with the `JMReserved-` prefix is rejected at startup, guarding against future collisions with any reserved name the framework introduces. ⚠️ **Breaking change**: this name is persisted as the connection identifier in the master database. If you run a standalone cluster, or any cluster that has used a fallback bucket, fully drain on the previous version before upgrading — the old reserved connection name won't be recognized after upgrading. New deployments are unaffected.

### Fixed

- **`IsStandalone` not applied when cluster is configured via `ConfigFromJson`** — `ClusterDefinition.IsStandalone` is now nullable. When it is null (not set in code), the runtime correctly falls back to the value already stored in the database (`modelToSave.IsStandalone`). Previously a null code-level value unconditionally overwrote the stored value with `false`, so a cluster configured as standalone purely through JSON would silently start in cluster mode.

- **Coordinator fallback bucket durability** — When no bucket is available for a job for too long, the Coordinator's temporary "fallback bucket" now persists jobs to the master database (through a dedicated reserved connection) instead of an in-process queue, so they survive a Coordinator restart instead of being lost.

- **Orphaned fallback buckets after a Coordinator crash** — If the worker that created a fallback bucket died, the bucket could get stuck forever instead of being cleaned up, leaking rows in the master database (the jobs themselves were never at risk — only the bucket's own bookkeeping). Fallback buckets are now destroyed automatically once their owning worker is confirmed dead.

- **`ProtectConnectionChanges` now actually protects a connection** — Previously this setting was silently dropped and never persisted, so no agent connection could ever truly become protected regardless of configuration. It's now saved correctly. Dead connections with no buckets left are automatically cleaned up after 30 minutes regardless of this setting — `ProtectConnectionChanges` only affects whether a silently-changed connection is rejected at startup, not how long a dead connection lingers.

- **A recreated agent connection, worker, or host could be marked dead immediately** — If a connection, worker, or host was deleted and later recreated, its previous heartbeat history wasn't cleared, and could incorrectly make the brand-new record look already stale enough to be considered dead. All three now correctly treat a freshly (re)created record as alive.

- **Worker "alive" window standardized to 45 seconds** — Agent workers previously used a 30-second window with no allowance for clock drift between machines, while agent connections used 90 seconds and hosts used 45 seconds. All three now consistently use the same 45-second window (30s heartbeat threshold + 15s clock-skew allowance).

- **SQL agent connections: possible false-positive "fingerprint has changed" failure on startup** — If two processes bootstrapped the same brand-new agent connection at the same moment (e.g. starting several instances together for the first time), a rare race could cause one of them to register a fingerprint that didn't match what was actually saved. On the next restart this could look like the connection had changed, which is a hard failure for connections with `ProtectConnectionChanges` enabled. The fingerprint registration is now atomic, so this can no longer happen.

- **Cached reads could serve stale data far longer than expected after a change** — Agent connection saves/deletes and host registration/stats/deletion notified other processes' caches to refresh *after* writing the change. If anything went wrong between the write and that notification (including the process simply stopping), other processes kept serving pre-change data until their cache entry's normal expiry, rather than picking up the change promptly. The notification now always happens first, so a change is never silently missed.

- **Fallback bucket ignored `DisablePriority`** — The temporary "fallback bucket" created when no real bucket is available for too long always used `Critical` priority, even if `Critical` had been explicitly disabled for the cluster. It now tries `Critical`, then `High`, then `Medium` (which can never be disabled), skipping any priority the cluster has disabled.

---

## JobMaster 0.0.9-alpha / JobMaster.Dashboard 0.0.2-alpha

### Fixed
Expand Down
14 changes: 7 additions & 7 deletions JobMaster.Api/ApiModels/ApiAgentWorker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ public class ApiAgentWorker : ApiClusterBaseModel
public string Id { get; set; } = string.Empty;
/// <summary>Display name of the worker.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Identifier of the agent connection this worker belongs to.</summary>
public string AgentConnectionId { get; set; } = null!;
/// <summary>Name of the agent connection this worker belongs to.</summary>
public string AgentConnectionName { get; set; } = null!;
/// <summary>Identifier of the agent connection this worker belongs to, or <c>null</c> for Coordinator workers.</summary>
public string? AgentConnectionId { get; set; }
/// <summary>Name of the agent connection this worker belongs to, or <c>null</c> for Coordinator workers.</summary>
public string? AgentConnectionName { get; set; }
/// <summary>UTC timestamp when the worker was registered.</summary>
public DateTime CreatedAt { get; set; }
/// <summary>Whether the worker is currently alive (sending heartbeats).</summary>
Expand Down Expand Up @@ -44,10 +44,10 @@ internal static ApiAgentWorker FromDomain(AgentWorkerModel model)
ClusterId = model.ClusterId,
Id = model.Id,
Name = model.Name,
AgentConnectionId = model.AgentConnectionId.IdValue,
AgentConnectionName = model.AgentConnectionId.Name,
AgentConnectionId = model.AgentConnectionId?.IdValue,
AgentConnectionName = model.AgentConnectionId?.Name,
CreatedAt = model.CreatedAt,
IsAlive = model.IsAlive,
IsAlive = model.IsAlive(),
StopRequestedAt = model.StopRequestedAt,
StopGracePeriod = model.StopGracePeriod,
LastHeartbeat = model.LastHeartbeat,
Expand Down
5 changes: 2 additions & 3 deletions JobMaster.Api/ApiModels/ApiJobQueryCriteria.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,8 @@ internal JobQueryCriteria ToDomainCriteria()
HostId = HostId,
BucketId = BucketId,
WorkerId = WorkerId,
ReadIsolationLevel = ReadIsolationLevel.FastSync,
SortBy = SortBy != null && !string.IsNullOrWhiteSpace(SortBy.Property)
? new SortByCriteria { Property = SortBy.Property, Ascending = SortBy.Ascending }
SortBy = SortBy != null && !string.IsNullOrWhiteSpace(SortBy.Property)
? new SortByCriteria { Property = SortBy.Property, Ascending = SortBy.Ascending }
: null,
};
}
Expand Down
1 change: 0 additions & 1 deletion JobMaster.Api/ApiModels/ApiMasterBucketQueryCriteria.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ internal MasterBucketQueryCriteria ToDomainCriteria()
WorkerLane = WorkerLane,
BucketType = BucketType,
BucketIds = BucketIds ?? [],
ReadIsolationLevel = ReadIsolationLevel.FastSync,
};
}
}
5 changes: 2 additions & 3 deletions JobMaster.Api/ApiModels/ApiRecurringScheduleQueryCriteria.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,8 @@ internal RecurringScheduleQueryCriteria ToDomainCriteria()
MetadataFilters = ApiGenericRecordValueFilterMappings.ParseMetadataFiltersJson(MetadataFiltersJson),
CountLimit = CountLimit ?? 25,
Offset = Offset ?? 0,
ReadIsolationLevel = ReadIsolationLevel.FastSync,
SortBy = SortBy != null && !string.IsNullOrWhiteSpace(SortBy.Property)
? new SortByCriteria { Property = SortBy.Property, Ascending = SortBy.Ascending }
SortBy = SortBy != null && !string.IsNullOrWhiteSpace(SortBy.Property)
? new SortByCriteria { Property = SortBy.Property, Ascending = SortBy.Ascending }
: null,
};
}
Expand Down
56 changes: 56 additions & 0 deletions JobMaster.Api/AspNetCore/JobMasterClusterApiExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using JobMaster.Abstractions.Ioc.Selectors;
using JobMaster.Ioc.Extensions;
using JobMaster.Sdk.Abstractions.Ioc.Definitions;
using JobMaster.Sdk.Abstractions.Ioc.Selectors;
using Microsoft.Extensions.DependencyInjection;

namespace JobMaster.Api.AspNetCore;

/// <summary>
/// Extension methods for registering JobMaster clusters in API-only mode.
/// </summary>
public static class JobMasterClusterApiExtensions
{
/// <summary>
/// Registers a JobMaster cluster for API-only access (monitoring and operations without job processing).
/// Only the cluster identity and master database connection are required — no workers or agent connections needed.
/// Use this when deploying the <c>JobMaster.Api</c> server as a standalone process that connects
/// directly to the master database without running any jobs.
/// </summary>
/// <param name="services">The application service collection.</param>
/// <param name="clusterId">Unique identifier for this cluster (letters, digits, hyphens, underscores; max 25 chars).</param>
/// <param name="configure">Fluent configuration delegate. Use a provider extension (e.g. <c>UsePostgresForMaster</c>) to supply the master database connection.</param>
public static IServiceCollection UseJobMasterClusterApi(this IServiceCollection services, string clusterId, Action<IBaseClusterConfigSelector<IClusterConfigSelector>> configure)
{
return AddJobMasterClusterExtensions.AddJobMasterCluster(services, clusterId, selector =>
{
configure(selector);
EnsureApiDefaultSet(selector);
});
}

/// <summary>
/// Registers a JobMaster cluster for API-only access (monitoring and operations without job processing).
/// Only the cluster identity and master database connection are required — no workers or agent connections needed.
/// Use this overload when the cluster ID is set inside the <paramref name="configure"/> delegate via <c>ClusterId(...)</c>.
/// </summary>
/// <param name="services">The application service collection.</param>
/// <param name="configure">Fluent configuration delegate. Call <c>ClusterId(...)</c> and a provider extension (e.g. <c>UsePostgresForMaster</c>) to configure the connection.</param>
public static IServiceCollection UseJobMasterClusterApi(this IServiceCollection services, Action<IBaseClusterConfigSelector<IClusterConfigSelector>> configure)
{
return AddJobMasterClusterExtensions.AddJobMasterCluster(services, selector =>
{
configure(selector);
EnsureApiDefaultSet(selector);
});
}

// When all clusters are API-only, no cluster will be marked as default and startup throws.
// The default has no meaning for API routing (routes are always cluster-ID-aware), so we silently
// promote the first API cluster to default to satisfy the runtime invariant.
private static void EnsureApiDefaultSet(IClusterConfigSelector selector)
{
if (!BootstrapBlueprintDefinitions.Clusters.Any(c => c.IsDefault))
selector.SetAsDefault();
}
}
Loading
Loading