fix(drafts): serialize draft session flushes and register reminders via fds__setReminder
Playwright Tests / test (push) Canceled after 0s

Make invoice/reminder live-draft edits concurrency-safe with an explicit
DraftFlushResult, add the fds__setReminder stored procedure, and harden the
date helper functions. Includes concurrency and SQL contract tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Stefan
2026-10-01 13:57:40 +02:00
co-authored by Claude Fable 5.1
parent d0002ccdad
commit 546648533b
21 changed files with 813 additions and 140 deletions
@@ -107,16 +107,15 @@ public partial class IntranetController
{
if (!HasForm("token")) return BadRequest400();
string token = Form("token");
var before = _invoiceDrafts.Get(token);
if (before == null) return DraftGone();
bool existed = !string.IsNullOrEmpty(before.InvId);
var fdInv = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (fdInv == null) return DraftGone();
// Create vs update is decided inside the flush (flushes of one session are serialised), not
// from the session state seen here: a save overlapping the first registration is an update.
var flush = await _invoiceDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (flush == null) return DraftGone();
var fdInv = flush.Data;
if (string.IsNullOrEmpty(fdInv.Id))
return await InvoiceIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
await _events.InvoiceDraftRegisteredAsync(fdInv, existed, UserAccountID);
await _events.InvoiceDraftRegisteredAsync(fdInv, flush.Updated, UserAccountID);
var after = _invoiceDrafts.Get(token);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, invid = fdInv.Id });
@@ -82,16 +82,14 @@ public partial class IntranetController
{
if (!HasForm("token")) return BadRequest400();
string token = Form("token");
var before = _reminderDrafts.Get(token);
if (before == null) return DraftGone();
bool existed = !string.IsNullOrEmpty(before.RemId);
var fdRem = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (fdRem == null) return DraftGone();
// Create vs update is decided inside the flush (serialised per session) — see HandleDraftSave.
var flush = await _reminderDrafts.FlushToDbAsync(token, UserAccountID, DbSec);
if (flush == null) return DraftGone();
var fdRem = flush.Data;
if (string.IsNullOrEmpty(fdRem.Id))
return await ReminderIssueResult("Der Zwischenstand konnte aufgrund eines Fehlers nicht gespeichert werden.");
await _events.ReminderDraftRegisteredAsync(fdRem, existed, UserAccountID);
await _events.ReminderDraftRegisteredAsync(fdRem, flush.Updated, UserAccountID);
var after = _reminderDrafts.Get(token);
if (after != null) await _draftNotifier.SignalDraftReadyAsync(after.Token, after.Version);
return await JSONAsync(new { ok = true, remid = fdRem.Id });
+22 -4
View File
@@ -1,6 +1,6 @@
---
status: Active
lastUpdated: 2026-09-09
lastUpdated: 2026-10-01
applyTo:
- "Fuchs/Services/InvoiceDraft*"
- "Fuchs/Services/IInvoiceDraft*"
@@ -99,6 +99,21 @@ Expiry: Server (timer) --SignalR draftExpiring{token,secondsLeft}--> warn "bit
(`RecomputeLevels`), rendered as an indent class in the editor and a per-level indent in the
PDF. `item.setprice` sums a header's **transitive** descendants. Structure deltas never
change values — only order, membership, levels and therefore position numbers.
- **Concurrency.** The editor fires patches, state fetches, previews and saves for one token
concurrently, and the session payload (`JObject`/`JArray`, the history list) is not thread-safe.
Every read and write of the payload runs under the session's `SyncRoot`, and everything the
controller serialises afterwards (`BuildState`, `GetHistory`, the `FdsInvoiceData` a preview or
flush uses) is a deep-cloned snapshot taken under that lock — never a live reference. Flushes are
additionally serialised per session on `FlushGate` (a `SemaphoreSlim`, awaited — `SyncRoot`
cannot be held across the registration's `await`): the create/update decision in
`FlushToDbAsync` is read-then-write across that await, so two overlapping saves of a
not-yet-registered draft (double-click on *Zwischenspeichern*, or *Zwischenspeichern* followed
by preview → confirm) would otherwise both call `fds__createInvoice` and leave an orphaned
invoice. The waiting save snapshots **after** it acquires the gate, so it persists the newer
state as an update of the row the first save created. `FlushToDbAsync` reports that decision
(`DraftFlushResult.Updated`) and the controller publishes `InvoiceDraftCreated`/`-Updated` from
it rather than from the session state it saw before the flush. Patches are never blocked by a
running save. `DraftSessionConcurrencyTests` pins both the lock and the gate.
- **Cache** (`InvoiceDraftCache`, singleton) stores sessions by token with an idle sliding
TTL; `InvoiceDraftExpiryService` (a `BackgroundService`) warns before, and evicts after,
the TTL. TTL/warn-lead are configurable under `Fuchs:DraftEditing`.
@@ -146,9 +161,12 @@ amount pair.
- **Orchestration** (`ReminderDraftEditService`, scoped) flushes to the DB by reusing
`IReminderService.RegisterReminderAsync`, and renders previews from a synthesised
`ReminderRegistration` (including the single-invoice `invoices` row the reminder PDF table
renders) so a preview needs no DB round-trip. **Note:** `RegisterReminderAsync` is create-only
(there is no `fds__setReminder` update proc), so a re-saved reminder draft does not update the
prior DB row — the primary flow (preview → confirm) flushes once immediately before finalising.
renders) so a preview needs no DB round-trip. A first save runs `fds__createReminder`; every
later save of the same session runs `fds__setReminder`, which updates the row in place (version
bump, same result shape) and refuses — `@Id` NULL → the batch throws → "nicht gespeichert" — once
the reminder is finalised or has a stored file. Flushes are serialised on the session's
`FlushGate` exactly like invoice flushes (see *Concurrency* above), so overlapping saves cannot
create two reminder rows either.
- **Cache/expiry** (`ReminderDraftCache` singleton + `ReminderDraftExpiryService`) mirror the
invoice ones and share the same `Fuchs:DraftEditing` TTL config.
- **Signals** reuse the shared `DraftPreviewHub` + `IDraftNotifier` unchanged — the token-keyed
+13
View File
@@ -0,0 +1,13 @@
namespace Fuchs.Services;
/// <summary>
/// Outcome of a draft flush ("Zwischenspeichern", <see cref="IInvoiceDraftService.FlushToDbAsync"/> /
/// <see cref="IReminderDraftService.FlushToDbAsync"/>). The draft service alone decides whether a
/// flush creates a DB row or updates the one an earlier flush created — flushes of one session are
/// serialised, so a save that overlaps the first registration of a new draft becomes an update — and
/// reports that decision here so the caller publishes the matching domain event instead of guessing
/// from the session state it saw before the flush.
/// </summary>
/// <param name="Data">The registered data; its <c>Id</c> is empty when the registration failed.</param>
/// <param name="Updated"><c>true</c> when the registration ran as an update of an existing DB draft, <c>false</c> for a create.</param>
public sealed record DraftFlushResult<T>(T Data, bool Updated) where T : class;
+6 -3
View File
@@ -45,10 +45,13 @@ public interface IInvoiceDraftService
/// <summary>
/// Persists the cached session to the DB via the existing invoice registration path
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. Flushes of one
/// session are serialised (<see cref="InvoiceDraftSession.FlushGate"/>): an overlapping save of a
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
/// ran as an update (for the success event), or null if the token is unknown.
/// </summary>
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Task<Document?> RenderPreviewAsync(string token);
+6 -3
View File
@@ -43,10 +43,13 @@ public interface IReminderDraftService
/// <summary>
/// Persists the cached session to the DB via the existing reminder registration path
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success. Flushes of one
/// session are serialised (<see cref="ReminderDraftSession.FlushGate"/>): an overlapping save of a
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
/// ran as an update (for the success event), or null if the token is unknown.
/// </summary>
Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token);
+92 -54
View File
@@ -60,27 +60,32 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
// The editor fires patches (and state/preview fetches) concurrently for one token; without
// the lock a block.replace on one request mutates Req while another enumerates it.
lock (session.SyncRoot)
{
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
}
/// <summary>
@@ -424,33 +429,40 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
}
// ── View state / history ────────────────────────────────────────────────
/// <remarks>
/// The result is serialised by the caller after this returns, so everything mutable is a
/// snapshot taken under the session lock — never a live reference into the session.
/// </remarks>
public object BuildState(InvoiceDraftSession session)
{
session.Touch();
return new
lock (session.SyncRoot)
{
token = session.Token,
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin,
@new = session.New,
req = session.Req,
sums = new
return new
{
total_net = session.Sums.TotalNet,
total_gross = session.Sums.TotalGross,
total_vat = session.Sums.TotalVat,
service_net = session.Sums.ServiceNet,
service_vat = session.Sums.ServiceVat,
vat = session.Sums.VatByRate,
block_net = session.Sums.NetByBlock
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
historyCount = session.History.Count,
notes = BuildNotes(session),
setDisplay = BuildSetDisplay(session)
};
token = session.Token,
version = session.Version,
invid = session.InvId,
isDraft = session.IsDraft,
admin = session.Admin.DeepClone(),
@new = session.New.DeepClone(),
req = session.Req.DeepClone(),
sums = new
{
total_net = session.Sums.TotalNet,
total_gross = session.Sums.TotalGross,
total_vat = session.Sums.TotalVat,
service_net = session.Sums.ServiceNet,
service_vat = session.Sums.ServiceVat,
vat = new Dictionary<string, decimal>(session.Sums.VatByRate),
block_net = new Dictionary<string, decimal>(session.Sums.NetByBlock)
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
historyCount = session.History.Count,
notes = BuildNotes(session).ToList(),
setDisplay = BuildSetDisplay(session)
};
}
}
/// <summary>
@@ -497,34 +509,60 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
.Select(n => new { text = n.Text, style = n.Style });
}
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
{
var session = _cache.Get(token);
if (session == null) return Array.Empty<ChangeHistoryEntry>();
lock (session.SyncRoot) return session.History.ToList();
}
// ── Flush / preview ────────────────────────────────────────────────────────
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
public async Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
bool change = !string.IsNullOrEmpty(session.InvId);
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
// One flush at a time per session: the create/update decision below is read-then-write across
// an await, so two overlapping saves of a new draft would both see an empty InvId and both
// call fds__createInvoice. The second save waits here, then runs as an update of the row the
// first one created (with its own, newer snapshot).
await session.FlushGate.WaitAsync();
try
{
session.InvId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
FdsInvoiceData fds;
string invId;
lock (session.SyncRoot)
{
fds = BuildFdsData(session); // deep-cloned snapshot — the await below runs outside the lock
invId = session.InvId;
}
bool change = !string.IsNullOrEmpty(invId);
var reg = await _invoices.RegisterInvoiceAsync(fds, change, invId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
lock (session.SyncRoot) session.InvId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return new DraftFlushResult<FdsInvoiceData>(reg, change);
}
finally
{
session.FlushGate.Release();
}
return reg;
}
public async Task<Document?> RenderPreviewAsync(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
FdsInvoiceData fds;
lock (session.SyncRoot)
{
fds = BuildFdsData(session);
fds.InvoiceRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true;
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
}
+78 -48
View File
@@ -59,27 +59,30 @@ public sealed class ReminderDraftEditService : IReminderDraftService
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
{
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
}
/// <summary>
@@ -144,24 +147,27 @@ public sealed class ReminderDraftEditService : IReminderDraftService
public object BuildState(ReminderDraftSession session)
{
session.Touch();
return new
lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
{
token = session.Token,
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New,
rem = session.Rem,
sums = new
return new
{
amount_total = session.Sums.AmountTotal,
amount_payed = session.Sums.AmountPayed,
amount_open = session.Sums.AmountOpen
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
historyCount = session.History.Count,
notes = BuildNotes(session)
};
token = session.Token,
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New.DeepClone(),
rem = session.Rem.DeepClone(),
sums = new
{
amount_total = session.Sums.AmountTotal,
amount_payed = session.Sums.AmountPayed,
amount_open = session.Sums.AmountOpen
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
historyCount = session.History.Count,
notes = BuildNotes(session)
};
}
}
/// <summary>
@@ -177,34 +183,58 @@ public sealed class ReminderDraftEditService : IReminderDraftService
return new { intro, closing };
}
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
{
var session = _cache.Get(token);
if (session == null) return Array.Empty<ChangeHistoryEntry>();
lock (session.SyncRoot) return session.History.ToList();
}
// ── Flush / preview ────────────────────────────────────────────────────────
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
public async Task<DraftFlushResult<FdsReminderData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildReminderData(session);
bool change = !string.IsNullOrEmpty(session.RemId);
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
// One flush at a time per session (see InvoiceDraftEditService.FlushToDbAsync): two overlapping
// saves of a new draft would otherwise both see an empty RemId and both call fds__createReminder.
await session.FlushGate.WaitAsync();
try
{
session.RemId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
FdsReminderData fds;
string remId;
lock (session.SyncRoot)
{
fds = BuildReminderData(session); // deep-cloned snapshot — the await below runs outside the lock
remId = session.RemId;
}
bool change = !string.IsNullOrEmpty(remId);
var reg = await _reminders.RegisterReminderAsync(fds, change, remId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
lock (session.SyncRoot) session.RemId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return new DraftFlushResult<FdsReminderData>(reg, change);
}
finally
{
session.FlushGate.Release();
}
return reg;
}
public Document? RenderPreview(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
FdsReminderData fds;
lock (session.SyncRoot)
{
fds = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
}
fds.IsDraft = true;
return _reminders.GenerateReminderPdf(fds, draft: true);
}
+23 -7
View File
@@ -63,6 +63,25 @@ public class ReminderService : IReminderService
return rem;
}
/// <summary>
/// The save batch for <see cref="RegisterReminderAsync"/>: <c>fds__createReminder</c> for a new
/// draft, <c>fds__setReminder</c> (update in place, same result row) for a re-saved one. Either
/// proc leaves <c>@Id</c> NULL when it did not save — the set proc refuses a finalised reminder
/// or one with a stored file — so the guard turns that into an error instead of an empty result
/// row the caller would read as "saved".
/// </summary>
internal static string BuildRegisterReminderSql(bool change)
{
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
sqlParts.Add(change
? "EXECUTE [dbo].[fds__setReminder] @RemId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;"
: "EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
sqlParts.Add(change
? "IF @Id IS NULL THROW 50001, N'fds__setReminder did not return a reminder id - reminder draft not saved (finalised, has a file, or unknown).', 1;"
: "IF @Id IS NULL THROW 50001, N'fds__createReminder did not return a reminder id - reminder not saved.', 1;");
return string.Join("\n", sqlParts);
}
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
string userAccountId, DatabaseSecurity dbSec)
{
@@ -82,20 +101,17 @@ public class ReminderService : IReminderService
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
};
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
if (!change || string.IsNullOrEmpty(remId))
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
else
pl.Add(SQL_VarChar("RemId", remId));
change = change && !string.IsNullOrEmpty(remId);
if (change) pl.Add(SQL_VarChar("RemId", remId));
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
var remdset = await getSQLDataSet_async(BuildRegisterReminderSql(change),
Conn, pl, tablenames: new[] { "rem" },
Security: dbSec, options: new FIS_SQLOptions());
if (!string.IsNullOrEmpty(remdset.Exception))
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
_logger.LogInformation("RegisterReminderAsync registered id={Id} (change={Change})", reminder.Id, change);
return reminder;
}
+18
View File
@@ -60,6 +60,24 @@ public sealed class InvoiceDraftSession
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
public List<ChangeHistoryEntry> History { get; } = new();
/// <summary>
/// Per-session lock. The editor fires patches, state fetches and previews concurrently for the
/// same token, and the payload (<see cref="Newtonsoft.Json.Linq.JArray"/>/<c>JObject</c>, the
/// history list) is not thread-safe — the draft service takes this lock around every read and
/// write of the payload, and hands out deep-cloned snapshots for anything serialised later.
/// </summary>
public object SyncRoot { get; } = new();
/// <summary>
/// Serialises flushes ("Zwischenspeichern") of this session. A flush reads <see cref="InvId"/>,
/// awaits the DB registration and then writes the id back; <see cref="SyncRoot"/> cannot be held
/// across that await, so without this gate two overlapping saves of a not-yet-registered draft
/// both see an empty id and both create a DB row. Only ever awaited (never held while taking
/// <see cref="SyncRoot"/>), so there is no lock-order inversion. Not disposed with the session on
/// purpose: a <see cref="SemaphoreSlim"/> owns no kernel handle unless its wait handle is touched.
/// </summary>
public SemaphoreSlim FlushGate { get; } = new(1, 1);
public void Touch() => LastAccessUtc = DateTime.UtcNow;
}
+1 -1
View File
@@ -1,4 +1,4 @@
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Linq;
namespace Fuchs.intranet;
+17
View File
@@ -60,6 +60,23 @@ public sealed class ReminderDraftSession
/// <summary>Automatic change history, appended on every applied patch. Cache-only (never persisted).</summary>
public List<ChangeHistoryEntry> History { get; } = new();
/// <summary>
/// Per-session lock (see <see cref="InvoiceDraftSession.SyncRoot"/>): the draft service takes it
/// around every read and write of the non-thread-safe payload, so concurrent patch/state/preview
/// requests for the same token cannot interleave.
/// </summary>
public object SyncRoot { get; } = new();
/// <summary>
/// Serialises flushes ("Zwischenspeichern") of this session. A flush reads <see cref="RemId"/>,
/// awaits the DB registration and then writes the id back; <see cref="SyncRoot"/> cannot be held
/// across that await, so without this gate two overlapping saves of a not-yet-registered draft
/// both see an empty id and both create a DB row. Only ever awaited (never held while taking
/// <see cref="SyncRoot"/>), so there is no lock-order inversion. Not disposed with the session on
/// purpose: a <see cref="SemaphoreSlim"/> owns no kernel handle unless its wait handle is touched.
/// </summary>
public SemaphoreSlim FlushGate { get; } = new(1, 1);
public void Touch() => LastAccessUtc = DateTime.UtcNow;
}