Communication — Kiến trúc hệ thống
Ranh giới rõ ràng: phần logic (Platform/automation) quyết định gửi gì; phần dispatch đảm bảo bản ghi tồn tại; pipeline gửi lo gửi thật. Handler automation không bao giờ gọi provider trực tiếp.
Toàn cảnh
Một câu: event và lịch từ bên trái dồn vào
NotificationScheduler; pipeline worker (Scheduler → Generator → Distributor) render và gửi; webhook cập nhật trạng thái vềOutgoingMessage.
Chọn đường gửi theo producer
| Producer | Đường |
|---|---|
| Manual / tin entity tức thời | NotificationJob → NotificationScheduler |
| Direct reminder | NotificationJob → NotificationScheduler → OutgoingMessage |
| UI-only reminder | NotificationJob → NotificationScheduler → UserMessage |
| Report subscription | AutomationScheduler → MessagingJob+Blob và/hoặc NotificationJob → NotificationScheduler → OutgoingMessage |
| Messaging campaign | MessagingJob+Blob → NotificationScheduler → OutgoingMessage |
| Automation bị skip/deactivate | Không gửi; kết quả ở AutomationScheduler.DispatchResultJson + màn hình support |
Ensure contracts (IDeliveryDispatcher)
Mọi artifact được tạo qua một hợp đồng Ensure*:
IDeliveryDispatcher
EnsureAutomationJobAsync EnsureNotificationJobAsync
EnsureMessagingJobAsync EnsureUserMessageAsync
EnsureOutgoingMessageAsync EnsureNotificationSchedulerAsyncMỗi method: tìm theo khóa tất định → kiểm tra tương thích (InputHash/ContentHash + immutable fields) → tạo nếu thiếu → trả về tọa độ ổn định. Tương thích → idempotent success; lệch immutable → xung đột (dừng gửi, báo support).
Key policy (rút gọn)
Khóa quyết định "mở lịch sử ở đâu" và "chống trùng thế nào".
NotificationJob
Manual / transactional: PartitionKey = PrimaryEntity.Guid
RowKey = {CreatedReverseTicks}_{NotificationJobGuid}
Direct automation reminder: PartitionKey = PrimaryEntity.Guid
RowKey = AutomationRowKey
Report subscription: PartitionKey = ReportSubscription.Guid
RowKey = AutomationRowKey
Messaging campaign: PartitionKey = {BusinessUnit.Guid}_Campaign
RowKey = Campaign.GuidPartitionKey là entity nơi người dùng mong đợi mở lịch sử — không phải id nội bộ automation. RowKey = AutomationRowKey là cầu idempotency từ automation sang notification. Notification event-derived dùng NotificationEventKey (xem Platform).
UserMessage
Customer: PartitionKey = user:{User.Id}
Staff: PartitionKey = employee:{Employee.Guid}
RowKey = {SourceReverseTicks}_{NotificationJob.RowKey}Prefix user: / employee: giữ hai miền danh tính tách biệt. SourceReverseTicks phải lấy từ timestamp nguồn ổn định (không phải write-time) để Generator retry không tạo dòng trùng.
OutgoingMessage + send claim
Notification-generated: PartitionKey = NotificationJob.RowKey
Report/campaign: PartitionKey = MessagingJob.Guid
RowKey = {RecipientKeyHash}_{DeliveryMethod} (+ _{AttemptNo} chỉ khi cố ý là attempt mới)RecipientKeyHash dẫn xuất từ danh tính người nhận ổn định + đích đã chuẩn hóa, không phải text hiển thị. Một dòng / người nhận / kênh; Generator retry tái dùng dòng.
Claim trước khi gọi provider:
Created/RetryReady -> Sending WHERE PartitionKey, RowKey, Status in (Created, RetryReady)Dòng đã Sending | SentToProvider | Delivered | Opened | FailedFinal → tin trùng trong queue thoát mà không gọi provider. Provider thành công → SentToProvider + provider message id; webhook đẩy tiếp. Chỉ một support-retry có audit mới reset về RetryReady.
NotificationScheduler dispatch guard
UNIQUE(ArtifactTypeId, ArtifactPartitionKey, ArtifactRowKey, NotificationEventTypeId)EnsureNotificationSchedulerAsync trả dòng có sẵn khi guard khớp. NotificationEventTypeId là discriminator của domain notification — map tường minh ở biên notification, không giả định bằng EventTypeId của EntityEvent.
Xử lý lỗi từng phần (partial failure)
| Tìm thấy khi retry | Hành vi |
|---|---|
NotificationJob có, scheduler thiếu | Tạo lại scheduler qua dispatch guard |
UserMessage thiếu dòng người nhận | Chèn dòng thiếu với khóa tất định |
OutgoingMessage đã có | Tái dùng; provider call do send claim quyết định |
MessagingJob có, blob không tương thích | Dừng, báo xung đột |
| Bất kỳ khóa nào hash không khớp | Dừng, báo xung đột |
SMS usage ledger (billing)
- Mỗi SMS provider chấp nhận ghi đúng một
SmsUsageở SQL, append-only — tổng hợp được theo BusinessUnit × khoảng thời gian (billing tháng) mà không cần quétOutgoingMessage. - Bản ghi mang
BusinessUnitId,NotificationEventTypeId(gán được cho Messaging Campaign / Incident / Onsite App SMS),SentOn(UTC lúc provider accept),MessageCount(=1),SmsCount. SmsCount= số segment provider tính phí: có provider-reported count → ghi đúng giá trị đó; không có → suy theo quy tắc encoding (GSM-7: 160/153; UCS-2: 70/67); luôn ≥ 1.- Chỉ ghi lúc provider accept: tin chưa dispatch, dispatch bị reject/throw, short-circuit thiếu mobile, simulated send không tới provider → không ghi, không bill.
- Idempotent theo outgoing message: mỗi bản ghi mang identity của outgoing message nó bill; lease recovery / redelivery / duplicate queue item không ghi lần hai.
- Ghi ledger là side effect, không phải precondition: persist thất bại không đổi outcome delivery, chỉ log error kèm outgoing message identity để reconcile.
Ví dụ thực tế: Twilio accept SMS 200 ký tự cho BU
ABC(eventIncidentUpdate). → Ledger ghi 1 recordMessageCount=1,SmsCount=2(UCS-2). → Billing tháng tổngSmsCounttheoBusinessUnitId
SentOn; lease recovery cùng tin không thêm record thứ hai.
Campaign: cancel, liveness, payload
- Cancel phụ thuộc trạng thái scheduler dispatch và báo outcome trung thực:
- Trước khi scheduler row được claim → deactivate schedule, không artifact nào sinh.
- Đang generation (row đã claim) → dừng tạo artifact còn lại tại checkpoint; artifact đã tới provider delivery path giữ nguyên; báo cancel một phần.
- Sau khi generation hoàn tất → không reset authoring state về draft; báo conflict.
- HTTP 200 = cancel hoàn toàn; HTTP 202 = cancel trong tiến trình;
X-Campaign-Cancel-Outcome= outcome code machine-readable (giữ nguyên numeric response body cũ). - Row đã claimed + đang generating mà bị edit/reschedule → reject, input đang chạy giữ nguyên.
- Liveness: generation refresh tín hiệu liveness của dispatch row tại từng checkpoint — chạy lâu không bị coi là stuck; worker chết giữa chừng → row thành eligible recovery sau timeout.
- Payload API authenticate + authorize theo business unit hiện tại: resolve campaign trong BU trước khi đọc payload storage; không resolve được → not-found, không đọc payload. Topi dùng endpoint này thay vì fetch thẳng
MessageJobBlob. - Serial generation (P1): config host giữ concurrency = 1; một instance Generator là tiền đề vận hành; nâng >1 = break invariant → phải có dispatch-claim trước.
Ví dụ thực tế: Operator cancel khi generation đang chạy, 300 email đã vào đường gửi. → Cancel. → Generation dừng tại checkpoint kế; 300 email giữ nguyên; response 202 — operator biết phần đã gửi, không bị "báo dừng" khi một phần đã đi.
Tiếp theo: file & worker cụ thể ở Luồng xử lý trong code.