forked from admin/VolcanicAgenticCore
feat(scheduler): atomic JSON job store
jobs.json is { "version": 1, "jobs": [...] } in VolcanicJson camelCase,
written to jobs.json.<pid>.tmp and moved into place through one semaphore.
A corrupt file is renamed jobs.json.corrupt-<timestamp> and loading starts
empty. Each entry is deserialised on its own, so an entry with an unknown
kind or a missing required field is dropped with a warning like any other
invalid job instead of setting the whole file aside.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
|
||||
namespace Volcanic.AgenticCore.Extensions.Scheduler;
|
||||
|
||||
/// <summary>
|
||||
/// <c>jobs.json</c>: <c>{ "version": 1, "jobs": [...] }</c> in <see cref="VolcanicJson"/> camelCase. Written atomically
|
||||
/// (temp file + rename) through one semaphore. A corrupt file is set aside and loading starts empty, so a bad file never
|
||||
/// keeps the host down; invalid jobs (including entries that don't deserialise) are dropped with a warning.
|
||||
/// </summary>
|
||||
public sealed class JobStore(string path, TimeProvider time, ILogger logger)
|
||||
{
|
||||
public const int Version = 1;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
|
||||
public string Path => path;
|
||||
|
||||
public async Task<IReadOnlyList<ScheduledJob>> LoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path)) return [];
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
StoredFile? file;
|
||||
try
|
||||
{
|
||||
file = VolcanicJson.Deserialize<StoredFile>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
file = null;
|
||||
}
|
||||
if (file is not { Version: Version, Jobs: not null })
|
||||
{
|
||||
var aside = $"{path}.corrupt-{time.GetUtcNow().ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture)}";
|
||||
File.Move(path, aside, overwrite: true);
|
||||
logger.LogError("The job store {Path} could not be read; it was moved to {Aside} and the scheduler starts empty", path, aside);
|
||||
return [];
|
||||
}
|
||||
|
||||
var jobs = new List<ScheduledJob>();
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < file.Jobs.Count; i++)
|
||||
{
|
||||
var (job, problem) = Read(file.Jobs[i]);
|
||||
problem ??= ids.Add(job!.Id) ? null : "duplicate id";
|
||||
if (problem is not null)
|
||||
{
|
||||
logger.LogWarning("Dropped scheduled job #{Index} ({Id}) from {Path}: {Problem}", i, job?.Id, path, problem);
|
||||
continue;
|
||||
}
|
||||
jobs.Add(job!);
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
public async Task SaveAsync(IReadOnlyList<ScheduledJob> jobs, CancellationToken cancellationToken)
|
||||
{
|
||||
await _writeGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
var temp = $"{path}.{Environment.ProcessId.ToString(CultureInfo.InvariantCulture)}.tmp";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!);
|
||||
var json = JsonSerializer.Serialize(new JobFile(Version, [.. jobs]), VolcanicJson.Indented);
|
||||
await File.WriteAllTextAsync(temp, json, cancellationToken).ConfigureAwait(false);
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(temp); } catch (IOException) { } catch (UnauthorizedAccessException) { }
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One entry of the file: the job, or why it's dropped. An entry that doesn't deserialise only loses itself.</summary>
|
||||
private static (ScheduledJob? Job, string? Problem) Read(JsonElement entry)
|
||||
{
|
||||
if (entry.ValueKind != JsonValueKind.Object) return (null, "not a JSON object");
|
||||
ScheduledJob? job;
|
||||
try
|
||||
{
|
||||
job = VolcanicJson.Deserialize<ScheduledJob>(entry);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return (null, ex.Message);
|
||||
}
|
||||
if (job is null) return (null, "empty entry");
|
||||
return (job, JobValidation.CheckStored(job));
|
||||
}
|
||||
|
||||
private sealed record JobFile(int Version, List<ScheduledJob> Jobs);
|
||||
|
||||
private sealed record StoredFile(int Version, List<JsonElement>? Jobs);
|
||||
}
|
||||
Reference in New Issue
Block a user