Repository navigation
Expand file tree
/
Copy pathProducerExecution.cs
More file actions
365 lines (324 loc) · 15.6 KB
/
Copy pathProducerExecution.cs
File metadata and controls
365 lines (324 loc) · 15.6 KB
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
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
using System.Diagnostics;
using System.Data.Common;
using LearnStack.SharedKernel.Jobs;
using LearnStack.SharedKernel.Identifiers;
using LearnStack.SharedKernel.Messaging;
using LearnStack.SharedKernel.Persistence;
namespace LearnStack.Application.Messaging;
/// <summary>
/// Scoped producer ancestry and sticky successful-production receipts (ADR-0057).
/// One logical execution uses a scope; nested invocations run sequentially.
/// </summary>
public sealed class ProducerExecution(IUnitOfWork unitOfWork)
{
private Invocation? _current;
private DomainDispatch? _dispatch;
/// <summary>Whether an invocation is open, independently of tenant admission.</summary>
public bool HasInvocation => _current is not null;
/// <summary>
/// Enrolls after announcement. An unresolved, event-free invocation is legal;
/// actual production must pass <see cref="RequireAnnouncement"/>.
/// </summary>
internal Invocation BeginInvocation(bool isTransactionOwner, UserId? causalActor = null,
Guid? incomingEventId = null, Type? operationType = null, BackgroundJobId? parentJobId = null)
{
if (_dispatch is not null)
{
Poison();
throw new InvalidOperationException("Domain translation cannot open another producer invocation.");
}
if (isTransactionOwner && _current is not null)
{
throw new InvalidOperationException("A producer owner is already active in this scope.");
}
var announcement = unitOfWork.GetTenantContextAnnouncement(unitOfWork.Transaction);
// An orphan UoW joiner is not a producer owner. It can finish event-free work,
// but cannot enlist production on a raw transaction nobody will complete here.
var hasOwner = isTransactionOwner
|| (_current is { HasOwner: true, Announcement: not null } parent
&& parent.Announcement == announcement);
// A retained invocation cannot lend ownership to a later raw transaction,
// even when Npgsql reuses the transaction object and the tenant is unchanged.
Activity? ownedActivity = null;
if (Activity.Current is not { IdFormat: ActivityIdFormat.W3C, Id: not null })
{
// Synchronous in the caller's execution context. ActivitySource alone returns
// null when the Seeder has no telemetry listener.
ownedActivity = new Activity("learnstack.producer")
.SetIdFormat(ActivityIdFormat.W3C).Start();
}
var invocation = new Invocation(this, _current, announcement, hasOwner, isTransactionOwner, ownedActivity,
_current is null ? causalActor : _current.CausalActor,
_current is null ? incomingEventId : _current.IncomingEventId, operationType,
_current is null ? parentJobId : _current.ParentJobId);
_current = invocation;
return invocation;
}
/// <summary>
/// Returns live authority for the enrolled, writable, unpoisoned frame only.
/// Retained snapshots and reused transaction objects cannot authorize a new unit.
/// </summary>
public TenantContextAnnouncement RequireAnnouncement()
{
var current = _current;
var live = unitOfWork.GetTenantContextAnnouncement(unitOfWork.Transaction);
if (current is null || !current.HasOwner || current.Announcement is null
|| live is null || live != current.Announcement
|| !unitOfWork.HasActiveTransaction || unitOfWork.Mode != TransactionMode.ReadWrite
|| unitOfWork.IsRollbackOnly)
{
throw new InvalidOperationException(
"Production requires an enrolled writable frame with its live tenant announcement.");
}
return live;
}
/// <summary>Refuses a store paired with another unit, even if announcements match.</summary>
public TenantContextAnnouncement RequireAnnouncement(IUnitOfWork expectedUnit)
{
if (!ReferenceEquals(unitOfWork, expectedUnit))
{
throw new InvalidOperationException("The outbox must share its producer's unit of work.");
}
return RequireAnnouncement();
}
/// <summary>
/// Builds dispatch metadata from the admitted frame, never producer arguments.
/// Organization authority comes from the SQL announcement even for a tenant-wide fact.
/// </summary>
public IntegrationEventEnvelope CreateEnvelope(IIntegrationEvent integrationEvent)
{
ArgumentNullException.ThrowIfNull(integrationEvent);
var announcement = RequireAnnouncement();
if (integrationEvent.TenantId == Guid.Empty
|| integrationEvent.TenantId == TenantId.PlatformSentinel.Value
|| integrationEvent.TenantId != announcement.TenantId.Value)
{
throw new InvalidOperationException("The event tenant must match the live announcement.");
}
var traceparent = _dispatch?.Origin.Traceparent ?? Activity.Current?.Id;
if (traceparent is null || !ActivityContext.TryParse(traceparent, null, out _))
{
throw new InvalidOperationException("An enrolled producer requires a W3C Activity.");
}
return new IntegrationEventEnvelope(integrationEvent, traceparent,
announcement.OrganizationId?.Value, CausationId: _dispatch?.EventId ?? _current!.IncomingEventId,
ActorUserId: _dispatch is null ? _current!.CausalActor : _dispatch.Origin.CausalActor);
}
/// <summary>Captures the exact trusted producer operation and live physical transaction.</summary>
public JobOrigin CaptureJobOrigin(IUnitOfWork expectedUnit)
{
var announcement = RequireAnnouncement(expectedUnit);
var invocation = _dispatch?.Origin.Invocation ?? _current!;
var operation = invocation.OperationType
?? throw new InvalidOperationException("Job submission requires a trusted typed operation.");
var traceparent = _dispatch?.Origin.Traceparent ?? Activity.Current?.Id;
if (traceparent is null || !ActivityContext.TryParse(traceparent, null, out _))
throw new InvalidOperationException("Job submission requires a W3C Activity.");
return new JobOrigin(this, _current!, invocation, _dispatch, unitOfWork.Transaction!, operation,
announcement, traceparent, _dispatch?.Origin.CausalActor ?? invocation.CausalActor,
_dispatch?.EventId ?? invocation.IncomingEventId, invocation.ParentJobId);
}
/// <summary>Revalidates after arbitrary serialization and before the actual INSERT.</summary>
public void RequireJobOrigin(JobOrigin origin, IUnitOfWork expectedUnit)
{
ArgumentNullException.ThrowIfNull(origin);
var announcement = RequireAnnouncement(expectedUnit);
if (!ReferenceEquals(origin.Execution, this) || !ReferenceEquals(origin.CurrentInvocation, _current)
|| !ReferenceEquals(origin.Dispatch, _dispatch) || !ReferenceEquals(origin.Transaction, unitOfWork.Transaction)
|| origin.Announcement != announcement
|| !ReferenceEquals(origin.Invocation, _dispatch?.Origin.Invocation ?? _current))
throw new InvalidOperationException("The job origin no longer belongs to the active producer invocation.");
}
/// <summary>Opaque scheduling origin, with diagnostic data that grants no standalone authority.</summary>
public sealed class JobOrigin
{
internal JobOrigin(ProducerExecution execution, Invocation currentInvocation, Invocation invocation,
object? dispatch, DbTransaction transaction, Type operationType, TenantContextAnnouncement announcement,
string traceparent, UserId? causalActor, Guid? incomingEventId, BackgroundJobId? parentJobId)
{
Execution = execution;
CurrentInvocation = currentInvocation;
Invocation = invocation;
Dispatch = dispatch;
Transaction = transaction;
OperationType = operationType;
Announcement = announcement;
Traceparent = traceparent;
CausalActor = causalActor;
IncomingEventId = incomingEventId;
ParentJobId = parentJobId;
}
internal ProducerExecution Execution { get; }
internal Invocation CurrentInvocation { get; }
internal Invocation Invocation { get; }
internal object? Dispatch { get; }
internal DbTransaction Transaction { get; }
public Type OperationType { get; }
public TenantContextAnnouncement Announcement { get; }
public string Traceparent { get; }
public UserId? CausalActor { get; }
public Guid? IncomingEventId { get; }
public BackgroundJobId? ParentJobId { get; }
}
/// <summary>Captures successful-save attribution without retaining ambient Activity state.</summary>
public Origin CaptureOrigin()
{
var announcement = RequireAnnouncement();
if (_dispatch is not null)
{
Poison();
throw new InvalidOperationException("Domain translation cannot produce more domain events.");
}
if (Activity.Current is not { IdFormat: ActivityIdFormat.W3C, Id: { } traceparent })
{
throw new InvalidOperationException("An enrolled producer requires a W3C Activity.");
}
return new Origin(this, _current!, announcement, traceparent, _current!.CausalActor);
}
/// <summary>Validates an origin against this physical transaction and its current owner.</summary>
internal void RequireOrigin(Origin origin)
{
ArgumentNullException.ThrowIfNull(origin);
var live = RequireAnnouncement();
if (!ReferenceEquals(origin.Execution, this) || live != origin.Announcement
|| !ReferenceEquals(OwnerOf(_current!), OwnerOf(origin.Invocation)))
{
throw new InvalidOperationException("The domain event origin no longer belongs to this transaction.");
}
}
internal void RequireOwner(Invocation owner)
{
if (!ReferenceEquals(_current, owner) || !owner.IsTransactionOwner)
{
throw new InvalidOperationException("Only the active physical transaction owner can drain domain events.");
}
EnsureCurrentInvocation();
}
internal void Poison() => unitOfWork.MarkRollbackOnly();
private static Invocation OwnerOf(Invocation invocation)
{
while (invocation.Parent is not null) invocation = invocation.Parent;
return invocation;
}
/// <summary>
/// Installs dispatch metadata synchronously; does not reopen the originating child
/// or alter AsyncLocal/Activity state across an asynchronous helper boundary.
/// </summary>
public IDisposable EnterDomainDispatch(Origin origin, Guid domainEventId)
{
RequireOrigin(origin);
RequireOwner(_current!);
if (_dispatch is not null || domainEventId == Guid.Empty)
{
Poison();
throw new InvalidOperationException("Domain dispatch must be sequential and carry an event identity.");
}
_dispatch = new DomainDispatch(origin, domainEventId);
return new DispatchScope(this, _dispatch);
}
/// <summary>Step 7 checks enrollment and authority without requiring a produced event.</summary>
internal void EnsureCurrentInvocation()
{
if (_current is null || !unitOfWork.HasActiveTransaction
|| _current.Announcement != unitOfWork.GetTenantContextAnnouncement(unitOfWork.Transaction))
{
throw new InvalidOperationException("The producer invocation no longer matches its transaction.");
}
}
/// <summary>
/// Called after successful outbox INSERT or successful-save promotion only.
/// Receipts survive child disposal and queue drain; they are not pending counts.
/// </summary>
public void RecordProducedWork()
{
_ = RequireAnnouncement();
RecordReceipt(_dispatch?.Origin.Invocation ?? _current!);
}
/// <summary>Records a saved fact against its retained invocation, including disposed children.</summary>
public void RecordProducedWork(Origin origin)
{
RequireOrigin(origin);
RecordReceipt(origin.Invocation);
}
private static void RecordReceipt(Invocation origin)
{
for (var invocation = origin; invocation is not null; invocation = invocation.Parent)
{
invocation.HasProducedWork = true;
}
}
/// <summary>Opaque attribution captured at the saving boundary.</summary>
public sealed class Origin
{
internal Origin(ProducerExecution execution, Invocation invocation,
TenantContextAnnouncement announcement, string traceparent, UserId? causalActor)
{
Execution = execution;
Invocation = invocation;
Announcement = announcement;
Traceparent = traceparent;
CausalActor = causalActor;
}
internal ProducerExecution Execution { get; }
internal Invocation Invocation { get; }
internal TenantContextAnnouncement Announcement { get; }
internal string Traceparent { get; }
internal UserId? CausalActor { get; }
}
private sealed record DomainDispatch(Origin Origin, Guid EventId);
// The collector owns the lexical scope token. ProducerExecution retains only
// immutable metadata, so it does not share disposal ownership with the caller.
private sealed class DispatchScope(ProducerExecution execution, DomainDispatch dispatch) : IDisposable
{
public void Dispose()
{
if (ReferenceEquals(execution._dispatch, dispatch)) execution._dispatch = null;
}
}
/// <summary>One invocation, retained by its coordinator through owner completion.</summary>
public sealed class Invocation : IDisposable
{
private readonly ProducerExecution _execution;
private readonly Activity? _ownedActivity;
private bool _disposed;
internal Invocation(
ProducerExecution execution, Invocation? parent,
TenantContextAnnouncement? announcement, bool hasOwner, bool isTransactionOwner,
Activity? ownedActivity, UserId? causalActor, Guid? incomingEventId,
Type? operationType, BackgroundJobId? parentJobId)
{
_execution = execution;
Parent = parent;
Announcement = announcement;
HasOwner = hasOwner;
IsTransactionOwner = isTransactionOwner;
_ownedActivity = ownedActivity;
CausalActor = causalActor;
IncomingEventId = incomingEventId;
OperationType = operationType;
ParentJobId = parentJobId;
}
internal Invocation? Parent { get; }
internal TenantContextAnnouncement? Announcement { get; }
internal bool HasOwner { get; }
internal bool IsTransactionOwner { get; }
internal UserId? CausalActor { get; }
internal Guid? IncomingEventId { get; }
internal Type? OperationType { get; }
internal BackgroundJobId? ParentJobId { get; }
/// <summary>Successful production by this invocation or any descendant.</summary>
public bool HasProducedWork { get; internal set; }
/// <inheritdoc />
public void Dispose()
{
if (_disposed) return;
if (!ReferenceEquals(_execution._current, this))
{
throw new InvalidOperationException("Producer invocations must close in LIFO order.");
}
_disposed = true;
_execution._current = Parent;
_ownedActivity?.Dispose();
}
}
}