1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
|
using System.Text.Json;
namespace Agent.Windows.Recovery;
public enum JournalEntryState { Prepared, Applied, Cleaned }
public sealed class JournalEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Kind { get; set; } = string.Empty;
public string Target { get; set; } = string.Empty;
public JournalEntryState State { get; set; }
public Dictionary<string, string> Data { get; set; } = new(StringComparer.Ordinal);
}
public sealed class JournalDocument
{
public int SchemaVersion { get; set; } = 1;
public Guid TaskId { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<JournalEntry> Entries { get; set; } = [];
}
public sealed class TaskResourceJournal
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly object _sync = new();
private readonly JournalDocument _document;
private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; }
public string Path { get; }
public Guid TaskId => _document.TaskId;
public IReadOnlyList<JournalEntry> Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } }
public static TaskResourceJournal Create(string journalDirectory, Guid taskId)
{
Directory.CreateDirectory(journalDirectory);
string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json");
if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'.");
var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId });
journal.Persist();
return journal;
}
public static TaskResourceJournal Open(string path)
{
JournalDocument document = JsonSerializer.Deserialize<JournalDocument>(File.ReadAllText(path), JsonOptions)
?? throw new InvalidDataException($"Journal '{path}' is empty.");
if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid.");
return new(path, document);
}
public Guid Prepare(string kind, string target, IReadOnlyDictionary<string, string> data)
{
lock (_sync)
{
var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) };
_document.Entries.Add(entry);
Persist();
return entry.Id;
}
}
public void MarkApplied(Guid entryId, IReadOnlyDictionary<string, string>? additionalData = null)
{
lock (_sync)
{
JournalEntry entry = Get(entryId);
if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied.");
if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value;
entry.State = JournalEntryState.Applied;
Persist();
}
}
public void MarkCleaned(Guid entryId)
{
lock (_sync)
{
JournalEntry entry = Get(entryId);
if (entry.State == JournalEntryState.Cleaned) return;
entry.State = JournalEntryState.Cleaned;
Persist();
}
}
public void CompleteAndDelete()
{
lock (_sync)
{
if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources.");
File.Delete(Path);
}
}
private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found.");
private void Persist()
{
string temporary = Path + ".tmp";
using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
{
JsonSerializer.Serialize(stream, _document, JsonOptions);
stream.Flush(flushToDisk: true);
}
File.Move(temporary, Path, overwrite: true);
}
private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) };
}
|