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.
Inbox Message: storage contract
UserMessage tách metadata/trạng thái khỏi body để list nhanh và không lộ nội dung riêng tư:
| Lớp | Lưu ở đâu | Nội dung chính |
|---|---|---|
| Primary row | Azure Table UserMessage qua CamelTableConnectionString | Identity, scope, source, preview, action, trạng thái recipient và ETag. |
| Private body | Azure Blob container message-content qua CamelBlobConnectionString | HTML/text; Table chỉ giữ MessageBodyBlobName. |
| Unread projection | Cùng Table UserMessage | Counter _InboxUnread và _InboxUnread_Account_{id} trong owner partition. |
| Retention projection | Cùng Table UserMessage | Index theo tháng _InboxUserMessageMaintenance_YYYYMM; không thay thế primary row. |
| Realtime projection | Redis Stream aimy:{environment}:user-message-inbox:events | Metadata invalidation; không phải nguồn dữ liệu Inbox. |
Redis dùng Azure Managed Redis một logical database (DB 0/default). Cache key của Tux được cô lập theo môi trường bằng prefix tux:{environment}:; stream Inbox dùng key template riêng ở bảng trên.
Ví dụ thực tế: Generator lưu body trước rồi ghi row
UserMessage. → API chỉ trả preview ở list, còn detail gọi body sau authorization. → Redis lỗi không làm mất row hay body.
Realtime Inbox: SSE nối sau persistence
Realtime là một delivery projection của UserMessage, không phải nơi lưu message hoặc authorize Inbox.
UserMessageđược persist trước; Redis publication là best-effort và không làm thất bại persistence.- Mỗi API instance đọc Redis Stream để fanout tới các SSE connection cục bộ; connection có buffer giới hạn.
Last-Event-IDhỗ trợ replay trong retention window; ngoài window thì yêu cầu REST sync.- Event chỉ invalidation: Tuke refresh unread, và refresh first page nếu Inbox đang mở; detail/body vẫn qua REST.
SSE và SignalR trong ngữ cảnh Inbox
| Tiêu chí | SSE đang dùng | SignalR | Đánh giá cho Inbox |
|---|---|---|---|
| Mô hình | Server → client | Hai chiều, hỗ trợ hub method | SSE vừa đủ cho invalidation một chiều |
| Dữ liệu | Event nhỏ, REST lấy dữ liệu thật | Có thể gửi event qua hub | Không cần đẩy full message qua socket |
| Auth/scope | Bearer header, user từ JWT sub | Hub auth, negotiate và user mapping | SSE ít contract mới hơn |
| Reconnect | Last-Event-ID + Redis replay + REST sync | Có auto reconnect, nhưng vẫn cần REST sync | Cả hai đều cần REST làm recovery |
| Vận hành | HTTP stream, heartbeat, proxy buffering | Hub route, client library, WebSocket/fallback, Azure SignalR | SSE nhẹ hơn cho use case hiện tại |
| Mở rộng | Không phù hợp chat/presence/typing | Phù hợp giao tiếp hai chiều | Chỉ chọn SignalR khi Inbox đổi bản chất |
Tux có hạ tầng SignalR dùng cho các flow khác, nhưng Inbox không dùng lại hub đó như một contract sẵn có. Chuyển Inbox sang SignalR sẽ cần hub route, user mapping, client lifecycle và kiểm thử reconnect/recovery mới; SignalR cũng không thay thế Redis replay hoặc REST revalidation.
Quyết định: giữ SSE cho Inbox. Chỉ cân nhắc SignalR khi cần chat hai chiều, typing/presence, read receipt realtime hoặc một hub dùng chung cho nhiều tính năng realtime.
Ví dụ thực tế: Một reminder mới tạo
UserMessage. → Redis fanoutinbox.changedtới Tuke. → Tuke refresh unread và first page bằng REST; không cần mở kênh hai chiều của SignalR.
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
Canonical owner partitions:
Account: PartitionKey = Account_{AccountId}
Employee: PartitionKey = Employee_{EmployeeId}
EnterpriseUser: PartitionKey = EnterpriseUser_{EnterpriseUserId}
Legacy read-only:
Customer: PartitionKey = Customer_{UserId}New row key là deterministic theo producer, không có một format duy nhất cho mọi producer. Các dạng đang gặp gồm inbox_{sourcePartitionKey}_{deterministicHash} và {reverseTicks}-{deterministicRowKey}; deployed legacy Incident còn có {reverseTicks}. Customer_{UserId} chỉ dùng compatibility-read/dedup cho dữ liệu cũ, không phải partition đích cho Campaign mới. CompatibilityHash bảo vệ các trường immutable; retry tương thích không thay đổi StatusId, ReadOn, ArchivedOn, SavedOn hoặc Reply*.
Query luôn bắt đầu từ portal identity → authorized owner scopes → Table query theo từng PartitionKey. List chỉ lấy Unread/Read, lọc thêm category/BU/site/account ở server; page size default 20, tối đa 100. Continuation token của từng scope được gói trong NextCursor opaque; khi merge nhiều scope, chỉ advance scope vừa chọn. Detail là point-read theo PartitionKey + RowKey; body đọc từ Blob sau authorization. State mutation dùng ETag conditional Replace.
Unread đọc counter projection, chỉ scan bounded partition khi counter chưa tồn tại. Retention xóa row unsaved quá 3 tháng theo CreatedOn, saved row được miễn; xóa cũng conditional theo ETag.
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.
Source-keyed delivery và Notification Settings
SourceKeyđược resolve tại Generator khi source entity đã nằm trong scope; message chưa resolve được dùng fallback hiện hành.NotificationJobvẫn là work batch, không dùng như source history grain.SourceKeyhiện hỗ trợ trace message theo source trong các flow đã wire; không tạo SQL reverse-index và không backfill/rewrite PartitionKey lịch sử.- Notification Settings list group theo recipient type và batch toggle copy Site/BU contact; delivery dedupe địa chỉ trùng, non-site-level không được nhận Site copy dù stored flag cũ còn true.
Ví dụ thực tế: Site contact và BU contact cùng là một email. → Không gửi hai bản. → Recipient resolution dedupe sau khi áp cả hai copy flag, trước khi tạo
OutgoingMessage.
Tiếp theo: file & worker cụ thể ở Luồng xử lý trong code.