Skip to content

Commit 24f08ad

Browse files
committed
refactor(dlr): defer delivery observability
1 parent c18ab9e commit 24f08ad

8 files changed

Lines changed: 80 additions & 116 deletions

File tree

‎docs/01-architecture.md‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,9 @@ sequenceDiagram
151151

152152
## DLR Handling
153153

154-
Outbound HTTP messages can include a Kannel-style `dlr-url`. Before accepting a submission, Sendium stores its gateway message ID. After the upstream SMSC returns `submit_sm_resp`, the client worker links that gateway ID to the exact `(provider name, provider message ID)` pair. The provider name defaults to the worker's full name; workers sharing an SMSC message-ID namespace can use the same `msg.hash.prefix`.
154+
Outbound HTTP messages can include a Kannel-style `dlr-url`, while downstream SMPP submissions request receipts through `registered_delivery`. Before accepting either submission, Sendium stores one `dlr_message` row containing the gateway message ID and downstream delivery target. After the upstream SMSC returns `submit_sm_resp`, the client worker links that gateway ID to the exact `(provider name, provider message ID)` pair in `provider_correlation`. The provider name defaults to the worker's full name; workers sharing an SMSC message-ID namespace can use the same `msg.hash.prefix`.
155155

156-
Different providers can reuse the same message ID independently. Reusing the same pair within one provider moves the correlation to the newest gateway message and clears it from the previous owner. Link and resolve transactions take a composite-key advisory lock and lock affected gateway rows in canonical UUID order, preventing crossed rebind and resolve deadlocks. Resolving a receipt transactionally consumes the tracked message and all of its correlations before callback forwarding or internal DLR queueing.
156+
Different providers can reuse the same message ID independently. Reusing the same pair within one provider moves the correlation to the newest gateway message and clears it from the previous owner. Link and resolve transactions take a composite-key advisory lock and lock affected gateway rows in canonical UUID order, preventing crossed rebind and resolve deadlocks. Intermediate `ACCEPTD` and `ENROUTE` receipts are acknowledged without invoking the tracker or consuming correlation. The first terminal receipt records its exact state and error, consumes every correlation for the gateway message, and either deletes a `NONE` delivery row or retains an HTTP/SMPP row as `PENDING`.
157157

158158
```mermaid
159159
sequenceDiagram
@@ -164,8 +164,9 @@ sequenceDiagram
164164
participant Tracker as StandardMessageTracker
165165
participant Service as DlrService
166166
participant Database as PostgreSQL DLR storage
167-
participant DLRHook as ForwardDlrService
168-
participant App as Originating application
167+
participant HTTP as HTTP DLR dispatcher
168+
participant App as Originating HTTP application
169+
participant SMPPApp as Originating SMPP client
169170
170171
Ingress->>Service: saveInitialState(gateway message ID)
171172
Service->>Database: Insert DLR message
@@ -187,16 +188,29 @@ sequenceDiagram
187188
Tracker->>Service: resolveDlr(provider pair, exact state/error)
188189
Service->>Database: Lock, resolve, consume correlations, retain pending delivery
189190
Database-->>Service: Resolved message state
190-
opt DLR callback URL exists
191-
Service->>DLRHook: Forward DLR callback
192-
DLRHook->>App: HTTP GET callback
191+
alt persistence succeeds
192+
Worker-->>SMSC: deliver_sm_resp (success)
193+
else persistence fails
194+
Worker-->>SMSC: deliver_sm_resp (SYSERR)
193195
end
194-
Service-->>Tracker: Resolved message state
196+
end
197+
198+
alt HTTP delivery channel
199+
loop Poll durable due rows
200+
HTTP->>Database: Start fenced attempt
201+
HTTP->>App: HTTP GET callback
202+
HTTP->>Database: Delete on success or persist retry/failure
203+
end
204+
else SMPP delivery channel
195205
Tracker->>Router: Enqueue internal MSG_DLR
196-
Worker-->>SMSC: deliver_sm_resp
206+
Router->>SMPPApp: deliver_sm receipt part(s)
207+
SMPPApp-->>Router: deliver_sm_resp for every part
208+
Router->>Database: Delete only after all responses succeed
197209
end
198210
```
199211

212+
HTTP and SMPP delivery are acknowledgement-driven and at-least-once. A crash after the receiver accepts a callback or response can cause the same receipt, including already acknowledged multipart SMPP parts, to be delivered again.
213+
200214
## MO Handling
201215

202216
Mobile-originated messages received from upstream SMPP providers are handled by the SMPP client worker. If the worker instance has an MO forwarding URL configured, `SmppClientWorker` forwards the MO through `ForwardMoService` using the configured forwarding format.

‎docs/07-webhooks.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Sendium can call external HTTP endpoints for delivery receipts and mobile-origin
44

55
## Delivery Receipt Callbacks
66

7-
HTTP submissions can include a `dlr-url` query parameter. Sendium stores the callback URL with the submitted message and calls it when the message state changes.
7+
HTTP submissions can include a `dlr-url` query parameter. Sendium stores the callback URL with the submitted message and calls it for the first terminal provider outcome. Intermediate `ACCEPTD` and `ENROUTE` receipts are acknowledged to the provider but are not forwarded.
88

99
Example HTTP submission:
1010

@@ -31,10 +31,12 @@ curl -G http://localhost:8080/sendsms \
3131
| :--- | :--- |
3232
| `1` | Delivered. |
3333
| `2` | Failed. |
34-
| `4` | Buffered or accepted for processing. |
35-
| `8` | Submitted to SMSC. |
34+
| `4` | Buffered or accepted for processing; retained as a compatibility mapping and not normally emitted by final-only receipt handling. |
35+
| `8` | Submitted to SMSC; retained as a compatibility mapping and not normally emitted by final-only receipt handling. |
3636

37-
DLR callbacks are sent as HTTP `GET` requests. HTTP status codes from `200` to `399` are treated as successful. Sendium makes up to 10 attempts with a 120 second delay between failed attempts. The retry schedule is process-local and does not survive a Sendium restart.
37+
DLR callbacks are sent as HTTP `GET` requests. The durable dispatcher checks PostgreSQL on a one-second schedule in serial batches of up to 100; a running batch delays the next check rather than overlapping it. Each request has a five-second timeout, and redirects are not followed; the original response status from `200` to `399` is treated as successful. Failures on attempts 1 through 9 are scheduled 120 seconds later. A failure on attempt 10 marks the row `FAILED`, and pending or failed rows are eligible for cleanup seven days after provider resolution. A malformed callback URI fails immediately without starting an HTTP attempt.
38+
39+
The retry schedule and attempt count survive a Sendium restart, but delivery is at-least-once rather than exactly-once. A crash or storage failure after the receiver accepts a callback can cause another request. Callback handlers must be idempotent and should use the gateway message ID supplied through `%s` as their deduplication key.
3840

3941
## Mobile-Originated Message Forwarding
4042

@@ -85,7 +87,7 @@ outSms.instance.testRoute.forward.mo.url = https://example.com/mo?from=%p&to=%P&
8587
outSms.instance.testRoute.forward.mo.format = FORM
8688
```
8789

88-
MO callbacks are sent as HTTP `POST` requests. HTTP status codes from `200` to `399` are treated as successful. Sendium makes up to 10 attempts with a 120 second delay between failed attempts. The retry schedule is process-local and does not survive a Sendium restart.
90+
Unlike durable DLR callbacks, MO callbacks are sent as process-local HTTP `POST` requests. HTTP status codes from `200` to `399` are treated as successful. Sendium makes up to 10 attempts with a 120 second delay between failed attempts. The MO retry schedule does not survive a Sendium restart.
8991

9092
## Security Notes
9193

‎docs/08-monitoring-observability.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ For a local Docker or development run, verify the endpoint with:
1818
curl http://localhost:8080/q/metrics
1919
```
2020

21-
The endpoint exposes Quarkus, JVM, HTTP server, and Micrometer runtime metrics. Sendium-specific business metrics require explicit instrumentation in code, such as counters, timers, or gauges registered through Micrometer.
21+
The endpoint exposes Quarkus, JVM, HTTP server, and Micrometer runtime metrics. Sendium's PostgreSQL DLR subsystem also registers its selected-backend gauge and storage-operation timers.
2222

2323
## Prometheus Configuration
2424

‎docs/13-dlr-persistence.md‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ The `sendium.dlr.persistence.enabled` build-time property controls this boundary
1212

1313
Message paths degrade rather than fail when the subsystem is absent. HTTP and downstream SMPP submissions are accepted and routed without gateway DLR state, undelivered downstream receipts fall back to the worker's in-memory retry, and provider receipts are not correlated, so Sendium emits no delivery receipts of its own. An application that embeds `sendium-core` without this subsystem is expected to supply its own `Tracker` and message store if it needs delivery receipts.
1414

15+
## Storage Model
16+
17+
`sendium_dlr.dlr_message` contains one row per gateway UUID. The row holds ingress metadata, the exact terminal provider outcome, the downstream delivery channel and status, the common HTTP/SMPP payload, the retry schedule, and the monotonically increasing attempt number used for fencing.
18+
19+
`sendium_dlr.provider_correlation` maps the exact `(provider_name, provider_message_id)` pair to the gateway UUID. Multiple provider correlations, including multipart provider IDs, can point to one message. Terminal resolution stores the resolving provider pair and outcome in `dlr_message`, removes every correlation for that gateway message, and retains the message row only when HTTP or SMPP delivery is required.
20+
1521
## Quick Start PostgreSQL
1622

1723
The generated Quick Start runtime creates:
@@ -98,9 +104,9 @@ PostgreSQL is fail-closed. If required persistence is unavailable, new HTTP subm
98104

99105
Provider message IDs are correlated within the outbound provider namespace rather than globally. The worker instance name is the default namespace; workers connected to the same SMSC account can share `msg.hash.prefix` when that SMSC may deliver their receipts interchangeably. Different providers may therefore return the same message ID without overwriting each other's state. The namespace must remain stable while correlations are outstanding: changing `msg.hash.prefix` or renaming a worker using the default makes earlier receipts unresolvable.
100106

101-
Sendium requests final delivery receipts from upstream SMPP providers. A valid unsolicited `ACCEPTD` or `ENROUTE` receipt is acknowledged successfully but is not forwarded and does not consume its provider correlation. The first terminal receipt consumes the correlation and produces the downstream DLR; later receipts for that provider message ID cannot resolve it. Multipart submissions retain this first-terminal behavior and do not aggregate delivery states across every segment.
107+
Sendium requests final delivery receipts from upstream SMPP providers. A valid unsolicited `ACCEPTD` or `ENROUTE` receipt, including a receipt identified through its SMPP ESM class, is acknowledged successfully but is not forwarded and does not consume its provider correlation. The first terminal receipt consumes every correlation for the gateway message and produces the downstream DLR; later receipts for those provider message IDs cannot resolve it. Multipart submissions retain this first-terminal behavior and do not aggregate delivery states across every segment. A terminal persistence failure returns `deliver_sm_resp` with `STATUS_SYSERR` so the provider can retry. A successful provider acknowledgement confirms durable resolution only; it does not wait for downstream HTTP or SMPP delivery.
102108

103-
A terminal receipt remains in `sendium_dlr.dlr_message` while HTTP or SMPP delivery is pending. The delivery attempt number is a fencing token: a stale completion or failure cannot mutate a newer attempt, and an adapter-local active-ID guard prevents duplicate starts within one process.
109+
A terminal receipt remains in `sendium_dlr.dlr_message` while HTTP or SMPP delivery is pending or after HTTP delivery reaches terminal `FAILED` status. The delivery attempt number is a fencing token: a stale completion or failure cannot mutate a newer attempt, and a process-local storage guard prevents duplicate starts within one Sendium instance.
104110

105111
## Retention
106112

@@ -114,6 +120,8 @@ The V1 retention thresholds are fixed application behavior, not environment sett
114120

115121
Cleanup is triggered by storage activity and runs no more than once per hour. These values are therefore eligibility thresholds, not exact physical deletion deadlines: idle records can remain in the database longer, and an active deployment can retain newly eligible state until the next cleanup pass. A provider receipt cannot be matched after its correlation has been removed. Making the thresholds or cleanup schedule configurable is outside the V1 storage replacement.
116122

123+
An HTTP row marked `FAILED` after attempt 10 remains eligible based on the original provider-resolution time, not the time of its final request. SMPP delivery has no fixed attempt cap, but a still-pending SMPP row is also eligible for cleanup seven days after resolution.
124+
117125
Cleanup is best-effort maintenance and is isolated from message handling. One caller at a time runs a pass while every other caller proceeds immediately, and a failed pass is logged and left until the next interval rather than rejecting the submission that triggered it.
118126

119127
## Durability Boundaries
@@ -122,13 +130,16 @@ Cleanup is best-effort maintenance and is isolated from message handling. One ca
122130
| :--- | :--- | :--- |
123131
| Initial DLR state for HTTP and downstream SMPP submissions | Persisted before HTTP routing or a successful SMPP acknowledgement. | Router and worker queues remain in memory. A process crash can lose queued outbound work even though its DLR row remains until cleanup. |
124132
| Gateway-to-provider message correlation | Survives Sendium restart after the provider message ID is linked. Intermediate `ACCEPTD` and `ENROUTE` receipts leave it intact. | The first terminal receipt consumes every correlation for the gateway message. |
125-
| Terminal HTTP/SMPP delivery | The common payload and exact provider outcome remain in one row until fenced completion. | Delivery scheduling and SMPP response batching are separate runtime concerns. |
133+
| Terminal HTTP/SMPP delivery | The common payload and exact provider outcome remain in one row until fenced completion. | Delivery is at-least-once; acknowledgement can be received before the final delete commits. |
126134
| Active delivery attempt | The database attempt number fences stale completion, retry, and failure updates. | The active-ID guard is process-local. Adapter recreation may start a new attempt for an attempt that was active before a crash. |
127135
| Multipart submission | Each acknowledged segment has provisional DLR state; completed aggregates update the primary state. | Multipart assembly and its pending timers are process-local and are not reconstructed after restart. |
128-
| HTTP DLR callback retry | Pending state and the next-attempt timestamp are durable. | The scheduler that consumes due rows is implemented separately. |
136+
| HTTP DLR callback retry | Pending state, attempt count, and next-attempt timestamp are durable. Checks are scheduled every second in non-overlapping serial batches; failures retry after 120 seconds and attempt 10 failures become `FAILED`. | A request accepted before a crash or failed completion update can be repeated. A slow batch delays later due callbacks. |
137+
| SMPP DLR delivery | One attempt covers every generated receipt part and completes only after matching successful `deliver_sm_resp` PDUs for all parts. Pending rows are enqueued when the same `system_id` binds. | Timeout, `generic_nack`, wrong/non-OK response, enqueue/send failure, or session closure releases the attempt. Replay is bind-driven rather than periodic, and partial success is not checkpointed. |
129138
| Database files | The Quick Start named volume survives normal container replacement and `docker compose down`. | Volume deletion, host-disk loss, and disaster recovery require backups or external PostgreSQL replication managed by the operator. |
130139

131-
These limits are intentional V1 boundaries. PostgreSQL provides DLR persistence and delivery fencing; it is not a distributed worker coordinator or a replacement for the router and worker queues.
140+
Downstream delivery uses bounded at-least-once attempt semantics, not exactly-once delivery. A crash or storage failure after an HTTP receiver accepts a callback, or after an SMPP client sends a successful `deliver_sm_resp`, can cause the receipt to be delivered again. Multipart SMPP replay can repeat already acknowledged parts. Consumers must be idempotent using the gateway or receipted message ID. HTTP retry limits, SMPP bind availability, and seven-day retention mean this is not an unlimited eventual-success guarantee.
141+
142+
These limits are intentional V1 boundaries. PostgreSQL provides DLR persistence and delivery fencing; it is not a distributed worker coordinator or a replacement for the router and worker queues. Attempt guards are process-local, so multiple active Sendium replicas sharing one database can start duplicate deliveries.
132143

133144
## Related Documentation
134145

‎sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/DlrDeliveryBatch.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ public String getGatewayMessageId() {
5858
return message.serial;
5959
}
6060

61+
public M getMessage() {
62+
return message;
63+
}
64+
6165
public void partSucceeded(int partOrdinal) {
6266
boolean complete = false;
6367
synchronized (this) {

‎sendium-core/src/main/java/gr/cytech/sendium/core/smpp/server/tasks/OutTask.java‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public void run() {
2828
boolean success;
2929
DlrDeliverSmReference<?> dlrReference =
3030
pdu.getReferenceObject() instanceof DlrDeliverSmReference<?> reference ? reference : null;
31+
StandardMessage durableDlr = dlrReference == null ? null : dlrReference.batch().getMessage();
3132
if (dlrReference != null && !dlrReference.batch().isActive()) {
3233
return;
3334
}
@@ -65,14 +66,18 @@ public void run() {
6566

6667
if (!success) {
6768
if (dlrReference != null) {
69+
if (MessageTrace.shouldLog(worker.getConfigurationProvider(), MessageTrace.EVENT_DELIVER_FAILED)) {
70+
logger.warn("message.deliver.failed worker={} {}", worker.getFullName(),
71+
MessageTrace.identifiers(durableDlr));
72+
}
6873
dlrReference.batch().fail("send_failed");
6974
} else {
7075
worker.outTaskFailed(pdu, msg);
7176
}
72-
} else if (!pdu.isResponse() && msg != null) {
77+
} else if (!pdu.isResponse() && (msg != null || durableDlr != null)) {
7378
if (MessageTrace.shouldLog(worker.getConfigurationProvider(), MessageTrace.EVENT_DELIVER_SENT)) {
7479
logger.info("message.deliver.sent worker={} deliverMsgId={} {}", worker.getFullName(),
75-
MessageTrace.value(deliverMsgId), MessageTrace.identifiers(msg));
80+
MessageTrace.value(deliverMsgId), MessageTrace.identifiers(msg != null ? msg : durableDlr));
7681
}
7782
}
7883
}

0 commit comments

Comments
 (0)