feat(notification-outbox): async Notify.Send with status handle
Notify.To(list).Send(subject,body) now generates a NotificationId GUID, enqueues a Notification-category message into the site Store-and-Forward Engine, and returns the NotificationId immediately (Task<string>). The NotificationId is the single idempotency key end-to-end: it is the S&F message Id, it is carried inside the buffered NotificationSubmit payload, and it is the id the forwarder submits to central. NotificationForwarder now deserializes the buffered payload as a NotificationSubmit and reads NotificationId from it (re-stamping only the site-owned SourceSiteId / SourceInstanceId), instead of deriving the id from StoreAndForwardMessage.Id. Adds NotifyHelper.Status(id): queries central via the site communication actor; reports the site-local Forwarding state while the notification is still buffered at the site, maps central's response when found, and Unknown otherwise. Adds a NotificationDeliveryStatus record. SiteCommunicationActor gains a NotificationStatusQuery forwarding handler mirroring NotificationSubmit. StoreAndForwardService.EnqueueAsync gains an optional messageId parameter and exposes GetMessageByIdAsync.
This commit is contained in:
@@ -87,21 +87,28 @@ public sealed class NotificationForwarder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a buffered S&F notification message onto a <see cref="NotificationSubmit"/>,
|
||||
/// returning <c>false</c> if the payload is unreadable.
|
||||
/// The <see cref="NotificationSubmit.NotificationId"/> is the central idempotency
|
||||
/// key and must be stable across every retry of the same buffered message, so it is
|
||||
/// derived from <see cref="StoreAndForwardMessage.Id"/> — a stable GUID assigned
|
||||
/// once at enqueue time.
|
||||
/// Maps a buffered S&F notification message onto the <see cref="NotificationSubmit"/>
|
||||
/// forwarded to central, returning <c>false</c> if the payload is unreadable.
|
||||
///
|
||||
/// The buffered payload IS a serialized <see cref="NotificationSubmit"/> written by
|
||||
/// the site <c>Notify.Send</c> enqueue path (Task 19). Its
|
||||
/// <see cref="NotificationSubmit.NotificationId"/> is the central idempotency key —
|
||||
/// it was generated by the script, equals the buffered row's
|
||||
/// <see cref="StoreAndForwardMessage.Id"/>, and is stable across every retry. The
|
||||
/// forwarder forwards the payload as-is except that it re-stamps the fields it
|
||||
/// authoritatively owns: <see cref="NotificationSubmit.SourceSiteId"/> (this site's
|
||||
/// id) and <see cref="NotificationSubmit.SourceInstanceId"/> (the buffered row's
|
||||
/// origin instance), and it falls the list name back to the S&F
|
||||
/// <see cref="StoreAndForwardMessage.Target"/> when the payload list name is blank.
|
||||
/// </summary>
|
||||
private bool TryBuildSubmit(StoreAndForwardMessage message, out NotificationSubmit submit)
|
||||
{
|
||||
submit = null!;
|
||||
|
||||
BufferedNotificationPayload? payload;
|
||||
NotificationSubmit? payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize<BufferedNotificationPayload>(message.PayloadJson);
|
||||
payload = JsonSerializer.Deserialize<NotificationSubmit>(message.PayloadJson);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -113,30 +120,25 @@ public sealed class NotificationForwarder
|
||||
return false;
|
||||
}
|
||||
|
||||
submit = new NotificationSubmit(
|
||||
NotificationId: message.Id,
|
||||
// A null OR empty/blank ListName falls back to the S&F Target — matching the
|
||||
// empty-string guard the former SMTP handler (NotificationDeliveryService)
|
||||
// applied, so an empty list name is never forwarded to central.
|
||||
ListName: string.IsNullOrEmpty(payload.ListName) ? message.Target : payload.ListName,
|
||||
Subject: payload.Subject ?? string.Empty,
|
||||
Body: payload.Message ?? string.Empty,
|
||||
SourceSiteId: _sourceSiteId,
|
||||
SourceInstanceId: message.OriginInstanceName,
|
||||
// The buffered payload does not currently carry the originating script;
|
||||
// Task 19 (the enqueue side) will add it. Null until then.
|
||||
SourceScript: null,
|
||||
SiteEnqueuedAt: message.CreatedAt);
|
||||
submit = payload with
|
||||
{
|
||||
// The NotificationId is the script-generated idempotency key carried in the
|
||||
// payload. Defend against a payload missing it by falling back to the
|
||||
// buffered row id, which the enqueue path pins to the same value.
|
||||
NotificationId = string.IsNullOrEmpty(payload.NotificationId)
|
||||
? message.Id
|
||||
: payload.NotificationId,
|
||||
// A null OR empty/blank ListName falls back to the S&F Target — so an empty
|
||||
// list name is never forwarded to central.
|
||||
ListName = string.IsNullOrEmpty(payload.ListName) ? message.Target : payload.ListName,
|
||||
// SourceSiteId/SourceInstanceId are authoritatively owned by the site: the
|
||||
// forwarder knows the real site id, and the buffered row records the origin
|
||||
// instance even after the instance is deleted.
|
||||
SourceSiteId = _sourceSiteId,
|
||||
SourceInstanceId = message.OriginInstanceName,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the payload shape written by the site notification enqueue path
|
||||
/// (<c>{ ListName, Subject, Message }</c>). Kept private to this forwarder — Task 19
|
||||
/// will reshape the enqueue payload, at which point this is updated alongside it.
|
||||
/// </summary>
|
||||
private sealed record BufferedNotificationPayload(
|
||||
string? ListName, string? Subject, string? Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -149,6 +149,13 @@ public class StoreAndForwardService
|
||||
/// When <c>false</c>, the caller has already made its own delivery attempt and the
|
||||
/// message is buffered directly for the retry sweep (the handler is not invoked here).
|
||||
/// </param>
|
||||
/// <param name="messageId">
|
||||
/// An explicit, caller-supplied message id. <c>null</c> (the default) makes the
|
||||
/// service mint a fresh GUID. The Notification Outbox enqueue path supplies its own
|
||||
/// id so the script-generated <c>NotificationId</c> is the single idempotency key —
|
||||
/// it is the buffered row's <see cref="StoreAndForwardMessage.Id"/>, it is carried
|
||||
/// inside the payload, and it is the id the forwarder submits to central.
|
||||
/// </param>
|
||||
public async Task<StoreAndForwardResult> EnqueueAsync(
|
||||
StoreAndForwardCategory category,
|
||||
string target,
|
||||
@@ -156,11 +163,12 @@ public class StoreAndForwardService
|
||||
string? originInstanceName = null,
|
||||
int? maxRetries = null,
|
||||
TimeSpan? retryInterval = null,
|
||||
bool attemptImmediateDelivery = true)
|
||||
bool attemptImmediateDelivery = true,
|
||||
string? messageId = null)
|
||||
{
|
||||
var message = new StoreAndForwardMessage
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Id = messageId ?? Guid.NewGuid().ToString("N"),
|
||||
Category = category,
|
||||
Target = target,
|
||||
PayloadJson = payloadJson,
|
||||
@@ -430,6 +438,17 @@ public class StoreAndForwardService
|
||||
return await _storage.GetMessageCountByOriginInstanceAsync(instanceName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notification Outbox: looks up a buffered message by its id, or <c>null</c> if it
|
||||
/// is not (or no longer) in the buffer. <c>Notify.Status</c> uses this to detect a
|
||||
/// notification still in transit at the site — central reports it not-found while
|
||||
/// the S&F buffer still holds it, which is the site-local <c>Forwarding</c> state.
|
||||
/// </summary>
|
||||
public async Task<StoreAndForwardMessage?> GetMessageByIdAsync(string messageId)
|
||||
{
|
||||
return await _storage.GetMessageByIdAsync(messageId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-14: Raises the S&F activity notification. StoreAndForward-009: the
|
||||
/// delegate is snapshotted (so a concurrent unsubscribe cannot NRE) and every
|
||||
|
||||
Reference in New Issue
Block a user