Skip to content
bitzorcas
中EN

Guide

Ticket Projection, Idempotency, and Event Ordering

Trace six ticket lifecycle events through generated wire payloads and the CAP consumer, then examine the final-ID defect, LastEventId idempotency, time ordering, retry, concurrent Upsert, and recovery requirements.

Last updated

TicketReportingEventConsumer folds Tickets lifecycle events into the current Rpt_TicketSummary snapshot. Topic publication, business payload mapping, failure propagation, and all six lifecycle handlers now exist. The remaining blockers are the opened event’s placeholder entity ID, the absence of an aggregate version, and concurrent Store semantics.

1. Publication and consumption contract

All six Ticket domain events inherit DomainEvent, implement IIntegrationEvent, and declare [IntegrationTopic]. The source generator maps each event type to its topic and scalar business fields. DomainEventDispatchPipelineBehavior adds the framework envelope fields:

CAP payload composition
eventId = DomainEvent.EventId
eventType = domain event type name
business fields = every public event property emitted by the generated mapper

The command transaction writes this message to the CAP Outbox through INotificationPublisher. Publication or commit failure propagates, rolling back business data and Outbox together.

Reporting binds the same body to separate consumer DTOs:

TopicTickets publisher typeReporting parameter typeBusiness fields
ticket.openedEvents.TicketOpenedTicketOpenedIntegrationEventTicketId, TenantId, RequesterId, Subject, PriorityName, OccurredAt
ticket.assignedEvents.TicketAssignedTicketAssignedIntegrationEventTicketId, TenantId, AssigneeId, AssignedBy, OccurredAt
ticket.startedEvents.TicketStartedTicketStartedIntegrationEventTicketId, TenantId, StartedBy, OccurredAt
ticket.resolvedEvents.TicketResolvedTicketResolvedIntegrationEventTicketId, TenantId, ResolvedBy, OccurredAt
ticket.closedEvents.TicketClosedTicketClosedIntegrationEventTicketId, TenantId, ClosedBy, OccurredAt
ticket.reopenedEvents.TicketReopenedTicketReopenedIntegrationEventTicketId, TenantId, ReopenedBy, OccurredAt
Rpt_TicketSummaryReporting consumerCAP OutboxGenerated topic registryTicket aggregateRpt_TicketSummaryReporting consumerCAP OutboxGenerated topic registryTicket aggregateTicket* DomainEvent + IIntegrationEventtopic + eventId/eventType + business fieldsbind Ticket*IntegrationEventread, order check, Upsert

Current tests call the consumer with constructed DTOs. They do not capture bytes from the real transactional Outbox and pass them through CAP model binding. Source-level field alignment is evidenced; host serialization is not yet pinned by an independent contract test.

2. Six lifecycle changes

EventRequires opened rowProjection write
openedNoCreate New with RequesterId, Subject, PriorityName, and OpenedAt
assignedYesSet AssigneeId and Assigned
startedYesSet InProgress
resolvedYesSet Resolved
closedYesSet Closed and ClosedAt=OccurredAt
reopenedYesSet Reopened and ClosedAt=null

AssigneeId now has a real writer, and Reopened can clear ClosedAt because Store update directly assigns existing.ClosedAt = summary.ClosedAt. Earlier documentation claiming a null-preserving merge or opened/closed-only coverage is obsolete.

3. Opened still carries the placeholder ID

Ticket.Open currently executes in this order:

Current blocking order
var ticket = new Ticket("0", tenantId, requesterId, subject, priority, nowUtc);
ticket.Raise(new TicketOpened(
ticket.Id, // still "0"
ticket.TenantId,
ticket.RequesterId,
ticket.Subject,
ticket.Priority.Name,
nowUtc));
// OpenTicket then calls TicketRepository.SaveAsync(ticket).
// IEntitySet<T>.AddAsync assigns the final persistence ID later.

The Entity base already provides RaiseWhenPersistenceIdAssigned(Func<TId, IDomainEvent>), but Ticket.Open does not use it. Consequently:

  1. opened writes a Mart row with TicketId="0";
  2. the saved Ticket response and assigned/started/… events use the final Snowflake ID;
  3. later consumers cannot find the opened row and throw “cannot be projected before its opened event”;
  4. CAP retry cannot repair an identity mismatch.

Opened should be registered through RaiseWhenPersistenceIdAssigned(id => new TicketOpened(id, ...)), with an end-to-end OpenTicketCommand → persistence → Outbox → Reporting test. A consumer must never guess which final ticket a 0 event belongs to.

4. Duplicate, stale, and predecessor handling

Non-opened handlers share this decision pattern:

Current projection decision
var existing = await GetExistingAsync(tenantId, ticketId, eventName, ct);
// LastEventId recognizes only a direct duplicate of the current last write.
if (existing.LastEventId == eventId)
return; // immediate duplicate
if (occurredAt < existing.LastUpdatedAt)
return; // older event time
// An equal timestamp is not stale and may overwrite.
// Production ordering therefore still needs Version/sequence for determinism.
Apply(existing);
existing.LastEventId = eventId;
existing.LastUpdatedAt = occurredAt;
await UpsertAsync(existing, eventName, ct);

Opened checks an immediate duplicate but does not compare OccurredAt; it creates a complete New snapshot. A non-opened event without its predecessor throws so CAP retries rather than acknowledging and losing the event. That corrects the old “closed-before-opened is silently lost” failure mode, but there is no gap ledger. A predecessor that can never succeed eventually exhausts retry policy.

5. LastEventId and OccurredAt boundaries

LastEventId recognizes only a direct redelivery of the most recent event: A,A. In A,B,A, the second A is no longer the last ID; the decision relies entirely on OccurredAt. An older replay is skipped, while an equal or incorrectly advanced timestamp can still overwrite.

OccurredAt is not a strict business sequence:

  • two transitions can share timestamp precision, and the comparison is <, not <=;
  • clock, import, or repair data can be equal or incorrect;
  • it cannot identify a missing version;
  • opened does not use the time guard.

A production projection should carry a monotonic per-Ticket AggregateVersion and store LastAppliedVersion in the Mart:

  • version <= current: duplicate/stale; acknowledge and count;
  • version == current + 1: apply atomically;
  • version > current + 1: persist a gap and request repair instead of skipping ahead.

An EventId inbox and version gate solve different problems. The inbox prevents arbitrary redelivery; version decides business order. Inbox write and Mart mutation should commit in the same transaction.

6. Failure and CAP retry

The consumer logs the exception type and rethrows. It does not disguise these conditions as successful consumption:

  • Mart read Result failure;
  • Mart Upsert Result failure;
  • non-opened event with no opened row;
  • another non-cancellation, non-out-of-memory exception.

OperationCanceledException and OutOfMemoryException are outside the catch filter and also propagate naturally. Logs do not include Subject or the complete payload.

The runtime still needs explicit failure classes:

ClassRecommended handling
Transient database/network failurebounded retry, then DLQ and alert
Missing opened predecessorrecord a gap, short retry, controlled source repair
Invalid schema/required fieldquarantine rather than unbounded retry
Duplicate/staleacknowledge and increment a metric
Concurrent version conflictreload and retry under the version rule

7. Store concurrency window

ReportingMartStore.UpsertTicketSummaryAsync still queries (TenantId,TicketId) before Add or Update. It has no atomic Upsert, ExpectedVersion, or affected-row compare-and-set.

Possible races include:

  • two first events both observe no row and compete on the unique key;
  • two consumers load the same old row, then the later commit overwrites the earlier update;
  • LastEventId, LastUpdatedAt, and business columns have no database condition;
  • equal-timestamp events resolve by commit order.

The unique index prevents duplicate physical rows, not incorrect business order. A dual-ORM suite must exercise unique-conflict recovery and conditional updates against real databases.

8. Current test evidence

TicketReportingEventConsumerTests verifies:

  • Opened → Assigned → Started → Resolved → Closed → Reopened;
  • AssigneeId and all six status names;
  • ClosedAt assignment and Reopened clearing;
  • immediate Reopened redelivery does not Upsert again;
  • an older Closed does not regress the Reopened snapshot.

It does not verify:

  • final identity in the Ticket.Open event;
  • actual CAP bytes emitted by the generated payload mapping;
  • A,B,A, equal OccurredAt, or version gaps;
  • retry/DLQ policy for a missing predecessor;
  • concurrent consumers and real unique-key conflicts;
  • SqlSugar/EF Core behavior parity;
  • replay, reconciliation, or shadow rebuild.

9. Replay and rebuild

The normal consumer should not call Tickets internals. A recovery surface can use one of these controlled sources:

  1. versioned event archive;
  2. a snapshot-export stream owned by Tickets;
  3. controlled replica/ETL;
  4. a shadow Mart followed by count, status-distribution, and sample reconciliation before atomic cutover.

The repository currently has no Reporting inbox, checkpoint, event-archive adapter, rebuild command, or reconciliation job. Rebuild remains a target capability.

10. Required projection matrix

  1. Opened carries the final TicketId after OpenTicket persistence, never 0.
  2. All six real transactional Outbox payloads bind to consumer DTOs.
  3. A,A; A,B,A; equal timestamp; older timestamp; version gap.
  4. Non-opened first, delayed opened, and permanently missing opened.
  5. Transient Mart read/write failure, retry exhaustion, and DLQ.
  6. Two consumers on one Ticket and first-insert unique conflict.
  7. Reopened explicitly clears ClosedAt.
  8. TenantId/TicketId isolation and malformed payloads.
  9. Crash recovery between inbox and Mart update.
  10. Live projection and shadow rebuild produce the same snapshot.

11. Review commands

Terminal window
# Publisher types, generated payload, and six subscriptions.
rg -n "IntegrationTopic|CapSubscribe|BuildParameters|Ticket(Open|Assign|Start|Resolv|Clos|Reopen)" \
src/Platform/{Tickets,Reporting} src/Framework/BitzOrcas.Application/Pipelines -g '*.cs'
# The final-ID fix must replace direct Raise with RaiseWhenPersistenceIdAssigned.
rg -n 'new Ticket\(|Raise\(new TicketOpened|RaiseWhenPersistenceIdAssigned' \
src/Platform/Tickets -g '*.cs'
# Idempotency, time gate, concurrent Upsert, and test evidence.
rg -n "LastEventId|LastUpdatedAt|FirstOrDefaultAsync|TicketReportingEventConsumerTests" \
src/Platform/Reporting tests -g '*.cs'

Back to Reporting · Ticket events and notifications · Testing and GA

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%