response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());
- int statusCode = response.statusCode();
+ if (!dlrService.completeDelivery(gatewayMsgId, attempt)) {
+ recordStorageError(gatewayMsgId, attempt, "complete");
+ }
+ } catch (RuntimeException e) {
+ recordStorageError(gatewayMsgId, attempt, "complete");
+ }
+ }
- if (statusCode >= 200 && statusCode < 400) {
- logger.info("DLR forwarded successfully for gatewayMsgId: {}", gatewayMsgId);
+ private void handleAttemptFailure(String gatewayMsgId, int attempt, String result) {
+ logger.warn("HTTP DLR delivery attempt failed for gatewayMsgId={} attempt={} outcome={}",
+ gatewayMsgId, attempt, result);
+ try {
+ boolean updated;
+ if (attempt < MAX_ATTEMPTS) {
+ updated = dlrService.retryDelivery(
+ gatewayMsgId, attempt, result, System.currentTimeMillis() + RETRY_INTERVAL_MS);
} else {
- handleFailure(url, gatewayMsgId, attempt, "HTTP " + statusCode);
+ updated = dlrService.failDelivery(gatewayMsgId, attempt, result);
}
- } catch (IOException | InterruptedException | RuntimeException e) {
- if (e instanceof InterruptedException) {
- Thread.currentThread().interrupt();
+ if (!updated) {
+ recordStorageError(gatewayMsgId, attempt, "finish");
}
- handleFailure(url, gatewayMsgId, attempt, e.getClass().getSimpleName() + ": " + e.getMessage());
+ } catch (RuntimeException e) {
+ recordStorageError(gatewayMsgId, attempt, "finish");
}
}
- private void handleFailure(String url, String gatewayMsgId, int attempt, String error) {
- if (attempt >= MAX_RETRIES) {
- logger.error("DLR forward failed completely after {} retries for gatewayMsgId: {}. Last error: {}",
- MAX_RETRIES, gatewayMsgId, error);
- } else {
- logger.warn("DLR forward attempt {} failed for gatewayMsgId: {}. Error: {}. Scheduling retry.",
- attempt, gatewayMsgId, error);
- scheduleRetry(url, gatewayMsgId, attempt);
+ private void failInvalidDelivery(String gatewayMsgId) {
+ logger.warn("Invalid HTTP DLR callback for gatewayMsgId={}", gatewayMsgId);
+ try {
+ if (!dlrService.failInvalidDelivery(gatewayMsgId, "invalid_uri")) {
+ recordStorageError(gatewayMsgId, 0, "invalid");
+ }
+ } catch (RuntimeException e) {
+ recordStorageError(gatewayMsgId, 0, "invalid");
}
}
- private void scheduleRetry(String url, String gatewayMsgId, int attempt) {
- try {
- Thread.sleep(RETRY_INTERVAL_MS);
- doForward(url, gatewayMsgId, attempt + 1);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- logger.error("Retry sleep interrupted for gatewayMsgId: {}", gatewayMsgId);
+ private void recordStorageError(String gatewayMsgId, int attempt, String operation) {
+ logger.error("HTTP DLR storage update failed for gatewayMsgId={} attempt={} operation={}",
+ gatewayMsgId, attempt, operation);
+ }
+
+ int mapToKannelType(MessageState.MessageStatus status) {
+ if (status == null) {
+ return DLR_BUFFERED;
}
+ return switch (status) {
+ case ACCEPTED -> DLR_BUFFERED;
+ case SENT -> DLR_SMSC_SUBMIT;
+ case DELIVERED -> DLR_DELIVERED;
+ case FAILED -> DLR_FAILED;
+ };
+ }
+
+ String buildForwardUrl(String baseUrl, String msgId, int kannelType) {
+ String result = baseUrl.replace(DLR_TYPE_PLACEHOLDER, String.valueOf(kannelType));
+ return result.replace(MSG_ID_PLACEHOLDER, msgId != null ? msgId : "");
}
-}
\ No newline at end of file
+}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/InMemoryDlrService.java b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/InMemoryDlrService.java
deleted file mode 100644
index b829b8e..0000000
--- a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/InMemoryDlrService.java
+++ /dev/null
@@ -1,574 +0,0 @@
-package gr.cytech.sendium.core.worker;
-
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.DeserializationFeature;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import gr.cytech.sendium.core.message.StandardMessage;
-import jakarta.annotation.PostConstruct;
-import jakarta.annotation.PreDestroy;
-import jakarta.enterprise.context.ApplicationScoped;
-import jakarta.inject.Inject;
-import org.h2.mvstore.MVStore;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.File;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.TimeUnit;
-
-/**
- * Stores DLR correlation state and unpushed SMPP DLRs.
- *
- *
- * The service uses H2 MVStore when available and falls back to in-memory maps if the store cannot be opened.
- * The primary/correlation maps track submitted messages until operator DLRs arrive. The unpushed-DLR maps
- * persist DLRs that could not be delivered to a disconnected SMPP client, then replay them when the matching
- * systemId reconnects.
- *
- *
- * This is an application-scoped singleton. The primary/correlation state follows the existing model of map-level
- * concurrency: each operation is safe to call from worker threads, but multi-step updates are not globally serialized.
- * Unpushed DLRs have stronger consistency requirements because each entry is split across payload, timestamp, and
- * systemId index maps. Those compound operations are guarded by {@code unpushedDlrLock}. Replay also claims keys
- * before returning them so concurrent reconnect callbacks for the same systemId cannot enqueue the same DLR twice.
- */
-@ApplicationScoped
-public class InMemoryDlrService {
- private static final Logger logger = LoggerFactory.getLogger(InMemoryDlrService.class);
- private static final long SEVEN_DAYS_MILLIS = TimeUnit.DAYS.toMillis(7);
- private static final long THREE_DAYS_MILLIS = TimeUnit.DAYS.toMillis(3);
- private static final long EXPIRY_CHECK_INTERVAL = TimeUnit.HOURS.toMillis(1);
-
- private static final String DB_PATH_PROPERTY = "sendium.dlr.db.path";
- private static final String DEFAULT_DB_PATH = "data/dlr-mvstore.db";
- private static final ObjectMapper mapper = new ObjectMapper()
- .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
- private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {
- };
-
- @Inject
- ForwardDlrService forwardDlrService;
-
- private final Object unpushedDlrStateLock = new Object();
- private final Set claimedUnpushedDlrKeys = ConcurrentHashMap.newKeySet();
-
- private MVStore store;
-
- private Map primaryStore;
- private Map correlationIndex;
- private Map primaryTimestamps;
- private Map correlationTimestamps;
- private Map unpushedDlrStore;
- private Map unpushedDlrTimestamps;
- private Map unpushedDlrIndex;
-
- private volatile long lastExpiryCheck = 0;
- @SuppressWarnings("unused")
- private volatile boolean initialized = false;
-
- @PostConstruct
- void init() {
- String dbPath = System.getProperty(DB_PATH_PROPERTY, DEFAULT_DB_PATH);
- File dbFile = new File(dbPath);
- File dbDir = dbFile.getParentFile();
-
- if (dbDir != null && !dbDir.exists()) {
- boolean created = dbDir.mkdirs();
- if (created) {
- logger.info("Created DLR database directory: {}", dbDir.getAbsolutePath());
- }
- }
-
- try {
- if (dbFile.exists() && dbFile.length() > 0) {
- store = new MVStore.Builder()
- .fileName(dbFile.getAbsolutePath())
- .autoCommitBufferSize(1024)
- .open();
- logger.info("Opened existing DLR database: " + dbPath);
- } else {
- store = new MVStore.Builder()
- .fileName(dbFile.getAbsolutePath())
- .open();
- logger.info("Created new DLR database: " + dbPath);
- }
-
- primaryStore = store.openMap("primaryStore");
- correlationIndex = store.openMap("correlationIndex");
- primaryTimestamps = store.openMap("primaryTimestamps");
- correlationTimestamps = store.openMap("correlationTimestamps");
- unpushedDlrStore = store.openMap("unpushedDlrStore");
- unpushedDlrTimestamps = store.openMap("unpushedDlrTimestamps");
- unpushedDlrIndex = store.openMap("unpushedDlrIndex");
-
- if (primaryStore == null || correlationIndex == null || unpushedDlrStore == null || unpushedDlrIndex == null) {
- logger.error("Failed to load maps from DB, falling back to in-memory");
- fallbackToInMemory();
- } else {
- logger.info("Loaded from DB - primaryStore: {}, correlationIndex: {}, unpushedDlrStore: {}, unpushedDlrIndex: {}",
- primaryStore.size(), correlationIndex.size(), unpushedDlrStore.size(), unpushedDlrIndex.size());
- initialized = true;
- }
- } catch (Exception e) {
- logger.warn("Failed to initialize MVStore, falling back to in-memory: ", e);
- fallbackToInMemory();
- }
-
- if (!initialized) {
- fallbackToInMemory();
- }
- }
-
- private void fallbackToInMemory() {
- store = null;
- primaryStore = new ConcurrentHashMap<>();
- correlationIndex = new ConcurrentHashMap<>();
- primaryTimestamps = new ConcurrentHashMap<>();
- correlationTimestamps = new ConcurrentHashMap<>();
- unpushedDlrStore = new ConcurrentHashMap<>();
- unpushedDlrTimestamps = new ConcurrentHashMap<>();
- unpushedDlrIndex = new ConcurrentHashMap<>();
- initialized = true;
- logger.info("Using in-memory mode (no persistence)");
- }
-
- @PreDestroy
- void onStop() {
- logger.info("InMemoryDlrService shutting down");
- saveAndClose();
- }
-
- private synchronized void saveAndClose() {
- if (store != null && !store.isClosed()) {
- try {
- store.commit();
- logger.info("Saved DLR database");
- } catch (Exception e) {
- logger.warn("Failed to commit DB: {}", e.getMessage());
- }
- try {
- store.close();
- logger.info("Closed DLR database");
- } catch (Exception e) {
- logger.warn("Failed to close DB: {}", e.getMessage());
- }
- }
- }
-
- public void saveInitialState(MessageState context) {
- if (primaryStore != null) {
- checkExpiry();
- try {
- String json = mapper.writeValueAsString(context);
- primaryStore.put(context.getGatewayMsgId(), json);
- primaryTimestamps.put(context.getGatewayMsgId(), System.currentTimeMillis());
- } catch (JsonProcessingException e) {
- logger.error("Failed to serialize MessageState for gatewayMsgId: {}", context.getGatewayMsgId(), e);
- }
- }
- }
-
- public void linkOperatorId(String gatewayMsgId, String operatorMsgId) {
- checkExpiry();
- if (primaryStore == null || correlationIndex == null) {
- return;
- }
-
- int maxRetries = 20;
- long retryIntervalMs = 200;
- MessageState state = null;
-
- for (int i = 0; i < maxRetries; i++) {
- String stateJson = primaryStore.get(gatewayMsgId);
- if (stateJson != null) {
- try {
- // Deserialize back to object
- state = mapper.readValue(stateJson, MessageState.class);
- break; // State found and parsed, exit the retry loop
- } catch (JsonProcessingException e) {
- logger.error("Failed to deserialize MessageState for gatewayMsgId: {}", gatewayMsgId, e);
- break;
- }
- }
- try {
- Thread.sleep(retryIntervalMs);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- logger.warn("Thread interrupted while retrying for gatewayMsgId: {}", gatewayMsgId);
- break;
- }
- }
-
- // Check if we successfully found the state after the retries
- if (state != null) {
- state.setOperatorMsgId(operatorMsgId);
- state.setStatus(MessageState.MessageStatus.SENT);
- state.setTimestamp(System.currentTimeMillis());
- try {
- primaryStore.put(gatewayMsgId, mapper.writeValueAsString(state));
- correlationIndex.put(operatorMsgId, gatewayMsgId);
- correlationTimestamps.put(operatorMsgId, System.currentTimeMillis());
- } catch (JsonProcessingException e) {
- logger.error("Failed to serialize updated MessageState", e);
- }
- } else {
- logger.warn("GatewayMsgId not found for linking after {} retries: {}", maxRetries, gatewayMsgId);
- }
- }
-
- public Optional resolveAndRemoveDlr(String operatorMsgId, int dlrState) {
- checkExpiry();
- if (correlationIndex == null || primaryStore == null) {
- return Optional.empty();
- }
- String gatewayMsgId = correlationIndex.get(operatorMsgId);
- if (gatewayMsgId == null) {
- logger.warn("No gatewayMsgId found for operatorMsgId: {} (expired or unknown)", operatorMsgId);
- return Optional.empty();
- }
-
- String stateJson = primaryStore.get(gatewayMsgId);
- if (stateJson != null) {
- try {
- MessageState state = mapper.readValue(stateJson, MessageState.class);
- state.setTimestamp(System.currentTimeMillis());
- state.setStatus(mapDlrStateToMessageStatus(dlrState));
-
- primaryStore.remove(gatewayMsgId);
- primaryTimestamps.remove(gatewayMsgId);
- correlationIndex.remove(operatorMsgId);
- correlationTimestamps.remove(operatorMsgId);
- logger.debug("Resolved and removed DLR for gatewayMsgId: {}", gatewayMsgId);
-
- String forwardUrl = state.getForwardDlrUrl();
- if (forwardUrl != null && !forwardUrl.isEmpty()) {
- forwardDlrService.forwardDlr(state);
- }
-
- return Optional.of(state);
- } catch (JsonProcessingException e) {
- logger.error("Failed to deserialize MessageState during resolve", e);
- }
- }
-
- logger.warn("MessageState not found for gatewayMsgId: {}", gatewayMsgId);
- return Optional.empty();
- }
-
- private MessageState.MessageStatus mapDlrStateToMessageStatus(int dlrState) {
- return switch (dlrState) {
- case 1 -> MessageState.MessageStatus.DELIVERED;
- case 2, 3, 4, 6, 7, 8 -> MessageState.MessageStatus.FAILED;
- case 5, 9 -> MessageState.MessageStatus.ACCEPTED;
- case 15 -> MessageState.MessageStatus.DELIVERED;
- default -> MessageState.MessageStatus.FAILED;
- };
- }
-
- public Optional getState(String gatewayMsgId) {
- checkExpiry();
- if (primaryStore == null) {
- return Optional.empty();
- }
-
- String stateJson = primaryStore.get(gatewayMsgId);
- if (stateJson != null) {
- try {
- return Optional.of(mapper.readValue(stateJson, MessageState.class));
- } catch (JsonProcessingException e) {
- logger.error("Failed to deserialize MessageState in getState", e);
- }
- }
- return Optional.empty();
- }
-
- public boolean markAsFailed(String gatewayMsgId) {
- checkExpiry();
- if (primaryStore == null) {
- return false;
- }
-
- String stateJson = primaryStore.get(gatewayMsgId);
- if (stateJson != null) {
- try {
- MessageState state = mapper.readValue(stateJson, MessageState.class);
- state.setStatus(MessageState.MessageStatus.FAILED);
- state.setTimestamp(System.currentTimeMillis());
- primaryStore.put(gatewayMsgId, mapper.writeValueAsString(state));
- return true;
- } catch (JsonProcessingException e) {
- logger.error("Failed to process MessageState in markAsFailed", e);
- }
- }
- return false;
- }
-
- /**
- * Persist a DLR that could not be pushed to the SMPP client.
- */
- public boolean saveUnpushedDlr(StandardMessage msg) {
- checkExpiry();
- if (unpushedDlrStore == null || unpushedDlrIndex == null || msg == null || msg.type != StandardMessage.MSG_DLR ||
- msg.systemId == null || msg.systemId.isBlank()) {
- return false;
- }
-
- String key = getUnpushedDlrKey(msg);
- synchronized (unpushedDlrStateLock) {
- try {
- unpushedDlrStore.put(key, mapper.writeValueAsString(UnpushedDlr.fromMessage(msg)));
- unpushedDlrTimestamps.put(key, System.currentTimeMillis());
- addKeyToUnpushedDlrIndex(msg.systemId, key);
- commitStore();
- logger.info("Saved unpushed DLR key: {}", key);
- return true;
- } catch (JsonProcessingException e) {
- logger.error("Failed to serialize unpushed DLR key: {}", key, e);
- return false;
- }
- }
- }
-
- /**
- * Load unpushed DLRs for one SMPP systemId without marking them for replay.
- */
- public List getUnpushedDlrs(String systemId) {
- return loadUnpushedDlrs(systemId, false);
- }
-
- /**
- * Load and claim unpushed DLRs for replay. Claimed entries are hidden from later claims until removed or released.
- */
- public List claimUnpushedDlrs(String systemId) {
- return loadUnpushedDlrs(systemId, true);
- }
-
- private List loadUnpushedDlrs(String systemId, boolean claimForReplay) {
- checkExpiry();
- List messages = new ArrayList<>();
- if (unpushedDlrStore == null || unpushedDlrIndex == null || systemId == null || systemId.isBlank()) {
- return messages;
- }
-
- boolean changed = false;
- synchronized (unpushedDlrStateLock) {
- for (String key : getUnpushedDlrKeys(systemId)) {
- if (claimForReplay && claimedUnpushedDlrKeys.contains(key)) {
- continue;
- }
-
- String msgJson = unpushedDlrStore.get(key);
- if (msgJson == null) {
- removeKeyFromUnpushedDlrIndex(systemId, key);
- changed = true;
- continue;
- }
- try {
- UnpushedDlr dlr = mapper.readValue(msgJson, UnpushedDlr.class);
- if (isUnpushedDlrForConnection(dlr, systemId)) {
- if (!claimForReplay || claimedUnpushedDlrKeys.add(key)) {
- messages.add(dlr.toMessage());
- }
- } else {
- removeKeyFromUnpushedDlrIndex(systemId, key);
- changed = true;
- }
- } catch (JsonProcessingException e) {
- logger.error("Failed to deserialize unpushed DLR key: {}. Removing corrupt entry", key, e);
- unpushedDlrStore.remove(key);
- unpushedDlrTimestamps.remove(key);
- claimedUnpushedDlrKeys.remove(key);
- removeKeyFromUnpushedDlrIndex(systemId, key);
- changed = true;
- }
- }
- if (changed) {
- commitStore();
- }
- }
-
- return messages;
- }
-
- /**
- * Remove a replayed DLR from all unpushed-DLR maps.
- */
- public boolean removeUnpushedDlr(StandardMessage msg) {
- if (unpushedDlrStore == null || unpushedDlrIndex == null || msg == null || msg.systemId == null || msg.systemId.isBlank()) {
- return false;
- }
-
- String key = getUnpushedDlrKey(msg);
- synchronized (unpushedDlrStateLock) {
- final boolean removed = unpushedDlrStore.remove(key) != null;
- unpushedDlrTimestamps.remove(key);
- claimedUnpushedDlrKeys.remove(key);
- removeKeyFromUnpushedDlrIndex(msg.systemId, key);
- if (removed) {
- commitStore();
- }
- return removed;
- }
- }
-
- /**
- * Make a claimed but not yet removed DLR eligible for a later replay attempt.
- */
- public void releaseUnpushedDlrClaim(StandardMessage msg) {
- if (msg == null || msg.systemId == null || msg.systemId.isBlank()) {
- return;
- }
-
- synchronized (unpushedDlrStateLock) {
- claimedUnpushedDlrKeys.remove(getUnpushedDlrKey(msg));
- }
- }
-
- private boolean isUnpushedDlrForConnection(UnpushedDlr dlr, String systemId) {
- return dlr != null && dlr.systemId != null && dlr.systemId.equals(systemId);
- }
-
- private String getUnpushedDlrKey(StandardMessage msg) {
- return String.join("|",
- nullToEmpty(msg.systemId),
- nullToEmpty(msg.serial),
- String.valueOf(msg.state),
- nullToEmpty(msg.errcode),
- String.valueOf(msg.msgId));
- }
-
- private void addKeyToUnpushedDlrIndex(String systemId, String key) throws JsonProcessingException {
- List keys = getUnpushedDlrKeys(systemId);
- if (!keys.contains(key)) {
- keys.add(key);
- unpushedDlrIndex.put(systemId, mapper.writeValueAsString(keys));
- }
- }
-
- private List getUnpushedDlrKeys(String systemId) {
- String keysJson = unpushedDlrIndex.get(systemId);
- if (keysJson == null || keysJson.isBlank()) {
- return new ArrayList<>();
- }
- try {
- return new ArrayList<>(mapper.readValue(keysJson, STRING_LIST_TYPE));
- } catch (JsonProcessingException e) {
- logger.error("Failed to deserialize unpushed DLR index for systemId: {}. Clearing corrupt index", systemId, e);
- unpushedDlrIndex.remove(systemId);
- return new ArrayList<>();
- }
- }
-
- private void removeKeyFromUnpushedDlrIndex(String systemId, String key) {
- if (systemId == null || unpushedDlrIndex == null) {
- return;
- }
- List keys = getUnpushedDlrKeys(systemId);
- if (!keys.remove(key)) {
- return;
- }
- if (keys.isEmpty()) {
- unpushedDlrIndex.remove(systemId);
- return;
- }
- try {
- unpushedDlrIndex.put(systemId, mapper.writeValueAsString(keys));
- } catch (JsonProcessingException e) {
- logger.error("Failed to serialize unpushed DLR index for systemId: {}. Clearing index", systemId, e);
- unpushedDlrIndex.remove(systemId);
- }
- }
-
- private String getSystemIdFromUnpushedDlrKey(String key) {
- int separator = key.indexOf('|');
- return separator >= 0 ? key.substring(0, separator) : key;
- }
-
- private String nullToEmpty(String value) {
- return value == null ? "" : value;
- }
-
- private void commitStore() {
- if (store != null && !store.isClosed()) {
- store.commit();
- }
- }
-
- private synchronized void checkExpiry() {
- long now = System.currentTimeMillis();
- if (now - lastExpiryCheck < EXPIRY_CHECK_INTERVAL) {
- return;
- }
- lastExpiryCheck = now;
-
- if (primaryStore == null || primaryTimestamps == null) {
- return;
- }
-
- for (String key : primaryTimestamps.keySet()) {
- Long ts = primaryTimestamps.get(key);
- if (ts != null && (now - ts) > SEVEN_DAYS_MILLIS) {
- primaryStore.remove(key);
- primaryTimestamps.remove(key);
- logger.debug("Expired primary entry: {}", key);
- }
- }
-
- if (correlationIndex != null && correlationTimestamps != null) {
- for (String key : correlationTimestamps.keySet()) {
- Long ts = correlationTimestamps.get(key);
- if (ts != null && (now - ts) > THREE_DAYS_MILLIS) {
- correlationIndex.remove(key);
- correlationTimestamps.remove(key);
- logger.debug("Expired correlation entry: {}", key);
- }
- }
- }
-
- boolean removedExpired = false;
- if (unpushedDlrStore != null && unpushedDlrTimestamps != null) {
- synchronized (unpushedDlrStateLock) {
- for (String key : unpushedDlrTimestamps.keySet()) {
- Long ts = unpushedDlrTimestamps.get(key);
- if (ts != null && (now - ts) > SEVEN_DAYS_MILLIS) {
- unpushedDlrStore.remove(key);
- unpushedDlrTimestamps.remove(key);
- claimedUnpushedDlrKeys.remove(key);
- removeKeyFromUnpushedDlrIndex(getSystemIdFromUnpushedDlrKey(key), key);
- removedExpired = true;
- logger.debug("Expired unpushed DLR entry: {}", key);
- }
- }
- }
- }
- if (removedExpired) {
- commitStore();
- }
- }
-
- public int getPrimaryStoreSize() {
- return primaryStore != null ? primaryStore.size() : 0;
- }
-
- public int getCorrelationIndexSize() {
- return correlationIndex != null ? correlationIndex.size() : 0;
- }
-
- public int getUnpushedDlrStoreSize() {
- return unpushedDlrStore != null ? unpushedDlrStore.size() : 0;
- }
-
- public int getUnpushedDlrIndexSize() {
- return unpushedDlrIndex != null ? unpushedDlrIndex.size() : 0;
- }
-
- public boolean isPersistent() {
- return store != null && !store.isClosed();
- }
-}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/ManagedDlrStorage.java b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/ManagedDlrStorage.java
new file mode 100644
index 0000000..1c367dc
--- /dev/null
+++ b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/ManagedDlrStorage.java
@@ -0,0 +1,178 @@
+package gr.cytech.sendium.core.worker;
+
+import io.agroal.api.AgroalDataSource;
+import io.micrometer.core.instrument.Gauge;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Timer;
+import io.quarkus.agroal.DataSource;
+import io.quarkus.arc.InjectableInstance;
+import io.quarkus.arc.properties.IfBuildProperty;
+import io.quarkus.runtime.Startup;
+import jakarta.annotation.PostConstruct;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+@Startup
+@ApplicationScoped
+@IfBuildProperty(name = "sendium.dlr.persistence.enabled", stringValue = "true", enableIfMissing = false)
+public class ManagedDlrStorage implements DlrStorage {
+ private static final String METRIC_NAME = "sendium.dlr.storage.operation";
+ private static final String BACKEND = "postgresql";
+ private static final String POSTGRESQL_PROBE_SQL = """
+ SELECT 1
+ FROM sendium_dlr.dlr_message
+ WHERE FALSE
+ """;
+
+ @Inject
+ @ConfigProperty(name = "quarkus.flyway.dlr.active", defaultValue = "false")
+ boolean flywayActive;
+
+ @Inject
+ @ConfigProperty(name = "quarkus.flyway.dlr.migrate-at-start", defaultValue = "false")
+ boolean flywayMigrateAtStart;
+
+ @Inject
+ @DataSource("dlr")
+ InjectableInstance postgresqlDataSource;
+
+ @Inject
+ MeterRegistry meterRegistry;
+
+ private final Map timers = new ConcurrentHashMap<>();
+
+ private DlrStorage delegate;
+ private AgroalDataSource selectedPostgresqlDataSource;
+
+ @PostConstruct
+ void initialize() {
+ boolean postgresqlActive = postgresqlDataSource.getHandle().getBean().isActive();
+ if (!postgresqlActive || !flywayActive || !flywayMigrateAtStart) {
+ throw new IllegalStateException(
+ "PostgreSQL DLR storage requires the active 'dlr' datasource and Flyway migration");
+ }
+ selectedPostgresqlDataSource = postgresqlDataSource.get();
+ delegate = new PostgresqlDlrStorage(selectedPostgresqlDataSource);
+
+ Gauge.builder("sendium.dlr.storage.selected", this, ignored -> 1.0)
+ .description("Active Sendium DLR storage backend")
+ .tag("backend", BACKEND)
+ .strongReference(true)
+ .register(meterRegistry);
+ }
+
+ String backend() {
+ return BACKEND;
+ }
+
+ void verifyPostgresqlSchema() throws SQLException {
+ if (selectedPostgresqlDataSource == null) {
+ return;
+ }
+ try (Connection connection = selectedPostgresqlDataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(POSTGRESQL_PROBE_SQL)) {
+ statement.executeQuery();
+ }
+ }
+
+ @Override
+ public void saveInitialState(MessageState state) {
+ timed("save_initial", () -> delegate.saveInitialState(state));
+ }
+
+ @Override
+ public void saveInitialStates(List states) {
+ timed("save_initial_batch", () -> delegate.saveInitialStates(states));
+ }
+
+ @Override
+ public void linkProviderMessageId(String gatewayMessageId, String providerName, String providerMessageId) {
+ timed("link_provider", () -> delegate.linkProviderMessageId(
+ gatewayMessageId, providerName, providerMessageId));
+ }
+
+ @Override
+ public Optional resolveDlr(String providerName, String providerMessageId,
+ MessageState.MessageStatus status, int dlrState, String errorCode) {
+ return timed("resolve", () -> delegate.resolveDlr(
+ providerName, providerMessageId, status, dlrState, errorCode));
+ }
+
+ @Override
+ public Optional getState(String gatewayMsgId) {
+ return timed("get_state", () -> delegate.getState(gatewayMsgId));
+ }
+
+ @Override
+ public List listPendingSmppDeliveries(String systemId) {
+ return timed("list_pending_smpp", () -> delegate.listPendingSmppDeliveries(systemId));
+ }
+
+ @Override
+ public List listDueHttpDeliveries(int limit) {
+ return timed("list_due_http", () -> delegate.listDueHttpDeliveries(limit));
+ }
+
+ @Override
+ public Optional startDeliveryAttempt(String gatewayMsgId,
+ MessageState.DeliveryChannel expectedChannel) {
+ return timed("start_delivery", () -> delegate.startDeliveryAttempt(gatewayMsgId, expectedChannel));
+ }
+
+ @Override
+ public boolean completeDelivery(String gatewayMsgId, int expectedAttempt) {
+ return timed("complete_delivery", () -> delegate.completeDelivery(gatewayMsgId, expectedAttempt));
+ }
+
+ @Override
+ public boolean retryDelivery(String gatewayMsgId, int expectedAttempt, String result, long nextAttemptAt) {
+ return timed("retry_delivery", () -> delegate.retryDelivery(
+ gatewayMsgId, expectedAttempt, result, nextAttemptAt));
+ }
+
+ @Override
+ public boolean failDelivery(String gatewayMsgId, int expectedAttempt, String result) {
+ return timed("fail_delivery", () -> delegate.failDelivery(gatewayMsgId, expectedAttempt, result));
+ }
+
+ @Override
+ public boolean failInvalidDelivery(String gatewayMsgId, String result) {
+ return timed("fail_invalid_delivery", () -> delegate.failInvalidDelivery(gatewayMsgId, result));
+ }
+
+ private T timed(String operation, Supplier action) {
+ Timer.Sample sample = Timer.start(meterRegistry);
+ try {
+ T result = action.get();
+ sample.stop(timer(operation, "success"));
+ return result;
+ } catch (RuntimeException | Error e) {
+ sample.stop(timer(operation, "error"));
+ throw e;
+ }
+ }
+
+ private void timed(String operation, Runnable action) {
+ timed(operation, () -> {
+ action.run();
+ return null;
+ });
+ }
+
+ private Timer timer(String operation, String outcome) {
+ return timers.computeIfAbsent(operation + '/' + outcome, ignored -> Timer.builder(METRIC_NAME)
+ .description("Sendium DLR storage operation latency")
+ .tags("backend", BACKEND, "operation", operation, "outcome", outcome)
+ .register(meterRegistry));
+ }
+}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/MessageState.java b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/MessageState.java
index 25dcecd..e241bc3 100644
--- a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/MessageState.java
+++ b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/MessageState.java
@@ -15,10 +15,20 @@ public class MessageState implements Serializable {
private String systemId;
private String sourceAddr;
private String destAddr;
- private String operatorMsgId;
+ private String providerName;
+ private String providerMessageId;
private String forwardDlrUrl;
private List reassembledParts;
private MessageStatus status;
+ private Integer dlrState;
+ private String errorCode;
+ private DeliveryChannel deliveryChannel = DeliveryChannel.NONE;
+ private DeliveryStatus deliveryStatus = DeliveryStatus.WAITING_PROVIDER;
+ private int deliveryAttemptCount;
+ private Long lastAttemptAt;
+ private Long nextAttemptAt;
+ private String lastDeliveryResult;
+ private Long resolvedAt;
private long timestamp;
public MessageState() {
@@ -34,8 +44,10 @@ public MessageState(String gatewayMsgId, String accountId, String systemId, Stri
this.systemId = systemId;
this.sourceAddr = sourceAddr;
this.destAddr = destAddr;
- this.operatorMsgId = null;
+ this.providerName = null;
+ this.providerMessageId = null;
this.status = MessageStatus.ACCEPTED;
+ this.deliveryAttemptCount = 0;
this.timestamp = System.currentTimeMillis();
this.forwardDlrUrl = forwardDlrUrl;
}
@@ -60,8 +72,12 @@ public String getDestAddr() {
return destAddr;
}
- public String getOperatorMsgId() {
- return operatorMsgId;
+ public String getProviderMessageId() {
+ return providerMessageId;
+ }
+
+ public String getProviderName() {
+ return providerName;
}
public String getForwardDlrUrl() {
@@ -72,6 +88,42 @@ public MessageStatus getStatus() {
return status;
}
+ public Integer getDlrState() {
+ return dlrState;
+ }
+
+ public String getErrorCode() {
+ return errorCode;
+ }
+
+ public DeliveryChannel getDeliveryChannel() {
+ return deliveryChannel;
+ }
+
+ public DeliveryStatus getDeliveryStatus() {
+ return deliveryStatus;
+ }
+
+ public int getDeliveryAttemptCount() {
+ return deliveryAttemptCount;
+ }
+
+ public Long getLastAttemptAt() {
+ return lastAttemptAt;
+ }
+
+ public Long getNextAttemptAt() {
+ return nextAttemptAt;
+ }
+
+ public String getLastDeliveryResult() {
+ return lastDeliveryResult;
+ }
+
+ public Long getResolvedAt() {
+ return resolvedAt;
+ }
+
public List getReassembledParts() {
return reassembledParts == null ? null : new ArrayList<>(reassembledParts);
}
@@ -80,14 +132,54 @@ public long getTimestamp() {
return timestamp;
}
- public void setOperatorMsgId(String operatorMsgId) {
- this.operatorMsgId = operatorMsgId;
+ public void setProviderMessageId(String providerMessageId) {
+ this.providerMessageId = providerMessageId;
+ }
+
+ public void setProviderName(String providerName) {
+ this.providerName = providerName;
}
public void setStatus(MessageStatus status) {
this.status = status;
}
+ public void setDlrState(Integer dlrState) {
+ this.dlrState = dlrState;
+ }
+
+ public void setErrorCode(String errorCode) {
+ this.errorCode = errorCode;
+ }
+
+ public void setDeliveryChannel(DeliveryChannel deliveryChannel) {
+ this.deliveryChannel = deliveryChannel;
+ }
+
+ public void setDeliveryStatus(DeliveryStatus deliveryStatus) {
+ this.deliveryStatus = deliveryStatus;
+ }
+
+ public void setDeliveryAttemptCount(int deliveryAttemptCount) {
+ this.deliveryAttemptCount = deliveryAttemptCount;
+ }
+
+ public void setLastAttemptAt(Long lastAttemptAt) {
+ this.lastAttemptAt = lastAttemptAt;
+ }
+
+ public void setNextAttemptAt(Long nextAttemptAt) {
+ this.nextAttemptAt = nextAttemptAt;
+ }
+
+ public void setLastDeliveryResult(String lastDeliveryResult) {
+ this.lastDeliveryResult = lastDeliveryResult;
+ }
+
+ public void setResolvedAt(Long resolvedAt) {
+ this.resolvedAt = resolvedAt;
+ }
+
public void setReassembledParts(List reassembledParts) {
this.reassembledParts = reassembledParts == null ? null : new ArrayList<>(reassembledParts);
}
@@ -102,4 +194,16 @@ public enum MessageStatus {
DELIVERED,
FAILED
}
+
+ public enum DeliveryChannel {
+ NONE,
+ HTTP,
+ SMPP
+ }
+
+ public enum DeliveryStatus {
+ WAITING_PROVIDER,
+ PENDING,
+ FAILED
+ }
}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/PostgresqlDlrStorage.java b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/PostgresqlDlrStorage.java
new file mode 100644
index 0000000..b18110e
--- /dev/null
+++ b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/PostgresqlDlrStorage.java
@@ -0,0 +1,874 @@
+package gr.cytech.sendium.core.worker;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.sql.DataSource;
+import java.sql.Array;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class PostgresqlDlrStorage implements DlrStorage {
+ private static final Logger logger = LoggerFactory.getLogger(PostgresqlDlrStorage.class);
+
+ private static final int DEFAULT_LINK_MAX_ATTEMPTS = 20;
+ private static final long DEFAULT_LINK_RETRY_INTERVAL_MILLIS = 200;
+ private static final long EXPIRY_CHECK_INTERVAL_MILLIS = TimeUnit.HOURS.toMillis(1);
+ private static final int MAX_DELIVERY_BATCH_SIZE = 1_000;
+ private static final int STARTING_ATTEMPT = -1;
+
+ private static final String STATE_COLUMNS = """
+ gateway_message_id, account_id, system_id, source_address, destination_address,
+ provider_name, provider_message_id, forward_dlr_url, reassembled_parts, provider_status,
+ dlr_state, error_code, delivery_channel, delivery_status, delivery_attempt_count,
+ last_attempt_at, next_attempt_at, last_delivery_result, resolved_at, updated_at
+ """;
+
+ private static final String SAVE_INITIAL_STATE_SQL = """
+ INSERT INTO sendium_dlr.dlr_message
+ (gateway_message_id, account_id, system_id, source_address, destination_address,
+ provider_name, provider_message_id, forward_dlr_url, reassembled_parts, provider_status,
+ dlr_state, error_code, delivery_channel, delivery_status, delivery_attempt_count,
+ last_attempt_at, next_attempt_at, last_delivery_result, resolved_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (gateway_message_id) DO UPDATE SET
+ account_id = EXCLUDED.account_id,
+ system_id = EXCLUDED.system_id,
+ source_address = EXCLUDED.source_address,
+ destination_address = EXCLUDED.destination_address,
+ provider_name = EXCLUDED.provider_name,
+ provider_message_id = EXCLUDED.provider_message_id,
+ forward_dlr_url = EXCLUDED.forward_dlr_url,
+ reassembled_parts = EXCLUDED.reassembled_parts,
+ provider_status = EXCLUDED.provider_status,
+ dlr_state = EXCLUDED.dlr_state,
+ error_code = EXCLUDED.error_code,
+ delivery_channel = EXCLUDED.delivery_channel,
+ delivery_status = EXCLUDED.delivery_status,
+ delivery_attempt_count = EXCLUDED.delivery_attempt_count,
+ last_attempt_at = EXCLUDED.last_attempt_at,
+ next_attempt_at = EXCLUDED.next_attempt_at,
+ last_delivery_result = EXCLUDED.last_delivery_result,
+ resolved_at = EXCLUDED.resolved_at,
+ created_at = CURRENT_TIMESTAMP,
+ updated_at = EXCLUDED.updated_at
+ WHERE dlr_message.delivery_status = 'WAITING_PROVIDER'
+ """;
+
+ private static final String LINK_MESSAGE_SQL = """
+ UPDATE sendium_dlr.dlr_message
+ SET provider_name = ?, provider_message_id = ?, provider_status = 'SENT',
+ updated_at = CURRENT_TIMESTAMP
+ WHERE gateway_message_id = ? AND delivery_status = 'WAITING_PROVIDER'
+ """;
+
+ private static final String LOCK_MESSAGE_SQL = """
+ SELECT 1
+ FROM sendium_dlr.dlr_message
+ WHERE gateway_message_id = ?
+ FOR UPDATE
+ """;
+
+ private static final String LOCK_CORRELATION_SQL = """
+ SELECT pg_advisory_xact_lock(hashtextextended(?, 0) # hashtextextended(?, 1))
+ """;
+
+ private static final String GET_CORRELATION_OWNER_SQL = """
+ SELECT gateway_message_id
+ FROM sendium_dlr.provider_correlation
+ WHERE provider_name = ? AND provider_message_id = ?
+ """;
+
+ private static final String SAVE_CORRELATION_SQL = """
+ WITH saved_correlation AS (
+ INSERT INTO sendium_dlr.provider_correlation
+ (provider_name, provider_message_id, gateway_message_id)
+ VALUES (?, ?, ?)
+ ON CONFLICT (provider_name, provider_message_id) DO UPDATE SET
+ gateway_message_id = EXCLUDED.gateway_message_id,
+ created_at = CURRENT_TIMESTAMP
+ RETURNING gateway_message_id
+ )
+ UPDATE sendium_dlr.dlr_message
+ SET provider_name = NULL, provider_message_id = NULL, updated_at = CURRENT_TIMESTAMP
+ WHERE provider_name = ? AND provider_message_id = ?
+ AND gateway_message_id <> ?
+ AND delivery_status = 'WAITING_PROVIDER'
+ AND EXISTS (SELECT 1 FROM saved_correlation)
+ """;
+
+ private static final String DELETE_CORRELATIONS_SQL = """
+ DELETE FROM sendium_dlr.provider_correlation
+ WHERE gateway_message_id = ?
+ """;
+
+ private static final String GET_STATE_SQL = """
+ SELECT %s
+ FROM sendium_dlr.dlr_message
+ WHERE gateway_message_id = ?
+ """.formatted(STATE_COLUMNS);
+
+ private static final String RESOLVE_STATE_SQL = """
+ UPDATE sendium_dlr.dlr_message
+ SET provider_name = ?,
+ provider_message_id = ?,
+ provider_status = ?,
+ dlr_state = ?,
+ error_code = ?,
+ delivery_status = CASE WHEN delivery_channel = 'NONE' THEN delivery_status ELSE 'PENDING' END,
+ next_attempt_at = CASE WHEN delivery_channel = 'HTTP' THEN CURRENT_TIMESTAMP ELSE NULL END,
+ resolved_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE gateway_message_id = ? AND delivery_status = 'WAITING_PROVIDER'
+ RETURNING %s
+ """.formatted(STATE_COLUMNS);
+
+ private static final String DELETE_STATE_SQL = """
+ DELETE FROM sendium_dlr.dlr_message
+ WHERE gateway_message_id = ?
+ """;
+
+ private static final String LIST_PENDING_SMPP_SQL = """
+ SELECT %s
+ FROM sendium_dlr.dlr_message
+ WHERE system_id = ? AND delivery_channel = 'SMPP' AND delivery_status = 'PENDING'
+ ORDER BY resolved_at, created_at, gateway_message_id
+ """.formatted(STATE_COLUMNS);
+
+ private static final String LIST_DUE_HTTP_SQL = """
+ SELECT %s
+ FROM sendium_dlr.dlr_message
+ WHERE delivery_channel = 'HTTP' AND delivery_status = 'PENDING'
+ AND next_attempt_at <= CURRENT_TIMESTAMP
+ ORDER BY next_attempt_at, gateway_message_id
+ LIMIT ?
+ """.formatted(STATE_COLUMNS);
+
+ private static final String START_DELIVERY_SQL = """
+ UPDATE sendium_dlr.dlr_message
+ SET delivery_attempt_count = delivery_attempt_count + 1,
+ last_attempt_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE gateway_message_id = ? AND delivery_channel = ? AND delivery_status = 'PENDING'
+ RETURNING %s
+ """.formatted(STATE_COLUMNS);
+
+ private static final String COMPLETE_DELIVERY_SQL = """
+ DELETE FROM sendium_dlr.dlr_message
+ WHERE gateway_message_id = ? AND delivery_status = 'PENDING' AND delivery_attempt_count = ?
+ """;
+
+ private static final String RETRY_DELIVERY_SQL = """
+ UPDATE sendium_dlr.dlr_message
+ SET last_delivery_result = ?, next_attempt_at = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE gateway_message_id = ? AND delivery_status = 'PENDING' AND delivery_attempt_count = ?
+ """;
+
+ private static final String FAIL_DELIVERY_SQL = """
+ UPDATE sendium_dlr.dlr_message
+ SET delivery_status = 'FAILED', last_delivery_result = ?, next_attempt_at = NULL,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE gateway_message_id = ? AND delivery_status = 'PENDING' AND delivery_attempt_count = ?
+ """;
+
+ private static final String FAIL_INVALID_DELIVERY_SQL = """
+ UPDATE sendium_dlr.dlr_message
+ SET delivery_status = 'FAILED', last_delivery_result = ?, next_attempt_at = NULL,
+ resolved_at = COALESCE(resolved_at, CURRENT_TIMESTAMP), updated_at = CURRENT_TIMESTAMP
+ WHERE gateway_message_id = ? AND delivery_status = 'PENDING'
+ """;
+
+ private static final String DELETE_EXPIRED_CORRELATIONS_SQL = """
+ DELETE FROM sendium_dlr.provider_correlation
+ WHERE created_at < CURRENT_TIMESTAMP - INTERVAL '3 days'
+ """;
+
+ private static final String DELETE_EXPIRED_MESSAGES_SQL = """
+ WITH expired_messages AS (
+ SELECT gateway_message_id
+ FROM sendium_dlr.dlr_message
+ WHERE (delivery_status = 'WAITING_PROVIDER'
+ AND created_at < CURRENT_TIMESTAMP - INTERVAL '7 days')
+ OR (delivery_status IN ('PENDING', 'FAILED')
+ AND resolved_at < CURRENT_TIMESTAMP - INTERVAL '7 days')
+ ORDER BY gateway_message_id
+ FOR UPDATE
+ )
+ DELETE FROM sendium_dlr.dlr_message message
+ USING expired_messages expired
+ WHERE message.gateway_message_id = expired.gateway_message_id
+ """;
+
+ private final DataSource dataSource;
+ private final int linkMaxAttempts;
+ private final long linkRetryIntervalMillis;
+ private final long expiryCheckIntervalMillis;
+ private final ConcurrentHashMap activeDeliveryAttempts = new ConcurrentHashMap<>();
+ private final AtomicBoolean expiryInProgress = new AtomicBoolean();
+ private volatile long lastExpiryCheck;
+
+ public PostgresqlDlrStorage(DataSource dataSource) {
+ this(dataSource, DEFAULT_LINK_MAX_ATTEMPTS, DEFAULT_LINK_RETRY_INTERVAL_MILLIS,
+ EXPIRY_CHECK_INTERVAL_MILLIS);
+ }
+
+ PostgresqlDlrStorage(DataSource dataSource, int linkMaxAttempts,
+ long linkRetryIntervalMillis) {
+ this(dataSource, linkMaxAttempts, linkRetryIntervalMillis, EXPIRY_CHECK_INTERVAL_MILLIS);
+ }
+
+ PostgresqlDlrStorage(DataSource dataSource, int linkMaxAttempts,
+ long linkRetryIntervalMillis, long expiryCheckIntervalMillis) {
+ this.dataSource = Objects.requireNonNull(dataSource, "dataSource");
+ if (linkMaxAttempts < 1 || linkRetryIntervalMillis < 0 || expiryCheckIntervalMillis < 0) {
+ throw new IllegalArgumentException("Invalid storage retry or expiry policy");
+ }
+ this.linkMaxAttempts = linkMaxAttempts;
+ this.linkRetryIntervalMillis = linkRetryIntervalMillis;
+ this.expiryCheckIntervalMillis = expiryCheckIntervalMillis;
+ }
+
+ @Override
+ public void saveInitialState(MessageState state) {
+ saveInitialStates(List.of(state));
+ }
+
+ @Override
+ public void saveInitialStates(List states) {
+ Objects.requireNonNull(states, "states");
+ if (states.isEmpty()) {
+ return;
+ }
+
+ Map finalStatesByGateway = new LinkedHashMap<>();
+ for (MessageState state : states) {
+ Objects.requireNonNull(state, "state");
+ validateState(state);
+ UUID gatewayMsgId = parseGatewayId(state.getGatewayMsgId());
+ finalStatesByGateway.remove(gatewayMsgId);
+ finalStatesByGateway.put(gatewayMsgId, state);
+ }
+ checkExpiry();
+
+ try (Connection connection = dataSource.getConnection()) {
+ connection.setAutoCommit(false);
+ try {
+ List> entries = new ArrayList<>(finalStatesByGateway.entrySet());
+ List correlatedStates = entries.stream()
+ .map(Map.Entry::getValue)
+ .filter(state -> state.getProviderMessageId() != null)
+ .sorted(Comparator.comparing(MessageState::getProviderName)
+ .thenComparing(MessageState::getProviderMessageId))
+ .toList();
+ lockCorrelations(connection, correlatedStates);
+ lockInitialMessageOwners(connection, entries, correlatedStates);
+
+ List> saved = new ArrayList<>();
+ try (PreparedStatement statement = connection.prepareStatement(SAVE_INITIAL_STATE_SQL)) {
+ for (Map.Entry entry : entries) {
+ setStateParameters(connection, statement, entry.getKey(), entry.getValue());
+ if (statement.executeUpdate() == 1) {
+ saved.add(entry);
+ }
+ }
+ }
+ for (Map.Entry entry : saved) {
+ deleteCorrelations(connection, entry.getKey());
+ }
+ for (int index = 0; index < saved.size(); index++) {
+ MessageState state = saved.get(index).getValue();
+ if (state.getProviderMessageId() != null && isLastCorrelationOwner(saved, index, state)) {
+ saveCorrelation(connection, state.getProviderName(), state.getProviderMessageId(),
+ saved.get(index).getKey());
+ }
+ }
+ connection.commit();
+ } catch (SQLException e) {
+ rollback(connection, e);
+ throw e;
+ }
+ } catch (SQLException e) {
+ throw failure("save initial DLR states", e);
+ }
+ }
+
+ @Override
+ public void linkProviderMessageId(String gatewayMessageId, String providerName, String providerMessageId) {
+ checkExpiry();
+ requireCorrelation(providerName, providerMessageId);
+ UUID gatewayId = parseGatewayId(gatewayMessageId);
+
+ for (int attempt = 0; attempt < linkMaxAttempts; attempt++) {
+ if (tryLinkProviderMessageId(gatewayId, providerName, providerMessageId)) {
+ return;
+ }
+ if (attempt + 1 < linkMaxAttempts) {
+ sleepBeforeLinkRetry();
+ }
+ }
+ throw new DlrStorageException("Gateway message state not found while linking provider message ID");
+ }
+
+ @Override
+ public Optional resolveDlr(String providerName, String providerMessageId,
+ MessageState.MessageStatus status, int dlrState, String errorCode) {
+ Objects.requireNonNull(status, "status");
+ requireCorrelation(providerName, providerMessageId);
+ checkExpiry();
+
+ try (Connection connection = dataSource.getConnection()) {
+ connection.setAutoCommit(false);
+ try {
+ lockCorrelation(connection, providerName, providerMessageId);
+ Optional gatewayMessageId = findCorrelationOwner(
+ connection, providerName, providerMessageId);
+ if (gatewayMessageId.isEmpty() || !lockMessage(connection, gatewayMessageId.get())) {
+ connection.rollback();
+ return Optional.empty();
+ }
+ Optional state = resolveState(connection, gatewayMessageId.get(), providerName,
+ providerMessageId, status, dlrState, errorCode);
+ if (state.isEmpty()) {
+ connection.rollback();
+ return Optional.empty();
+ }
+ deleteCorrelations(connection, gatewayMessageId.get());
+ if (state.get().getDeliveryChannel() == MessageState.DeliveryChannel.NONE) {
+ deleteState(connection, gatewayMessageId.get());
+ }
+ connection.commit();
+ return state;
+ } catch (SQLException e) {
+ rollback(connection, e);
+ throw e;
+ }
+ } catch (SQLException e) {
+ throw failure("resolve DLR state", e);
+ }
+ }
+
+ @Override
+ public Optional getState(String gatewayMsgId) {
+ checkExpiry();
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(GET_STATE_SQL)) {
+ statement.setObject(1, parseGatewayId(gatewayMsgId));
+ try (ResultSet resultSet = statement.executeQuery()) {
+ return resultSet.next() ? Optional.of(readState(resultSet)) : Optional.empty();
+ }
+ } catch (SQLException e) {
+ throw failure("read DLR state", e);
+ }
+ }
+
+ @Override
+ public List listPendingSmppDeliveries(String systemId) {
+ checkExpiry();
+ if (systemId == null || systemId.isBlank()) {
+ return List.of();
+ }
+ return listStates(LIST_PENDING_SMPP_SQL, statement -> statement.setString(1, systemId),
+ "list pending SMPP deliveries");
+ }
+
+ @Override
+ public List listDueHttpDeliveries(int limit) {
+ checkExpiry();
+ if (limit < 1) {
+ throw new IllegalArgumentException("Delivery limit must be positive");
+ }
+ int boundedLimit = Math.min(limit, MAX_DELIVERY_BATCH_SIZE);
+ return listStates(LIST_DUE_HTTP_SQL, statement -> statement.setInt(1, boundedLimit),
+ "list due HTTP deliveries");
+ }
+
+ @Override
+ public Optional startDeliveryAttempt(String gatewayMsgId,
+ MessageState.DeliveryChannel expectedChannel) {
+ Objects.requireNonNull(expectedChannel, "expectedChannel");
+ if (expectedChannel == MessageState.DeliveryChannel.NONE) {
+ throw new IllegalArgumentException("A delivery attempt requires HTTP or SMPP channel");
+ }
+ UUID gatewayId = parseGatewayId(gatewayMsgId);
+ if (activeDeliveryAttempts.putIfAbsent(gatewayId, STARTING_ATTEMPT) != null) {
+ return Optional.empty();
+ }
+
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(START_DELIVERY_SQL)) {
+ statement.setObject(1, gatewayId);
+ statement.setString(2, expectedChannel.name());
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next()) {
+ activeDeliveryAttempts.remove(gatewayId, STARTING_ATTEMPT);
+ return Optional.empty();
+ }
+ MessageState state = readState(resultSet);
+ activeDeliveryAttempts.replace(gatewayId, STARTING_ATTEMPT, state.getDeliveryAttemptCount());
+ return Optional.of(state);
+ }
+ } catch (SQLException e) {
+ activeDeliveryAttempts.remove(gatewayId, STARTING_ATTEMPT);
+ throw failure("start DLR delivery attempt", e);
+ }
+ }
+
+ @Override
+ public boolean completeDelivery(String gatewayMsgId, int expectedAttempt) {
+ return finishAttempt(gatewayMsgId, expectedAttempt, COMPLETE_DELIVERY_SQL,
+ statement -> {
+ statement.setObject(1, parseGatewayId(gatewayMsgId));
+ statement.setInt(2, expectedAttempt);
+ }, "complete DLR delivery");
+ }
+
+ @Override
+ public boolean retryDelivery(String gatewayMsgId, int expectedAttempt, String result, long nextAttemptAt) {
+ return finishAttempt(gatewayMsgId, expectedAttempt, RETRY_DELIVERY_SQL,
+ statement -> {
+ statement.setString(1, normalizeResult(result));
+ statement.setObject(2, toOffsetDateTime(nextAttemptAt));
+ statement.setObject(3, parseGatewayId(gatewayMsgId));
+ statement.setInt(4, expectedAttempt);
+ }, "retry DLR delivery");
+ }
+
+ @Override
+ public boolean failDelivery(String gatewayMsgId, int expectedAttempt, String result) {
+ return finishAttempt(gatewayMsgId, expectedAttempt, FAIL_DELIVERY_SQL,
+ statement -> {
+ statement.setString(1, normalizeResult(result));
+ statement.setObject(2, parseGatewayId(gatewayMsgId));
+ statement.setInt(3, expectedAttempt);
+ }, "fail DLR delivery");
+ }
+
+ @Override
+ public boolean failInvalidDelivery(String gatewayMsgId, String result) {
+ checkExpiry();
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(FAIL_INVALID_DELIVERY_SQL)) {
+ statement.setString(1, normalizeResult(result));
+ statement.setObject(2, parseGatewayId(gatewayMsgId));
+ return statement.executeUpdate() == 1;
+ } catch (SQLException e) {
+ throw failure("mark invalid DLR delivery failed", e);
+ }
+ }
+
+ private boolean finishAttempt(String gatewayMsgId, int expectedAttempt, String sql,
+ StatementBinder binder, String operation) {
+ if (expectedAttempt < 1) {
+ throw new IllegalArgumentException("Expected attempt must be positive");
+ }
+ checkExpiry();
+ UUID gatewayId = parseGatewayId(gatewayMsgId);
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ binder.bind(statement);
+ return statement.executeUpdate() == 1;
+ } catch (SQLException e) {
+ throw failure(operation, e);
+ } finally {
+ activeDeliveryAttempts.remove(gatewayId, expectedAttempt);
+ }
+ }
+
+ private List listStates(String sql, StatementBinder binder, String operation) {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql)) {
+ binder.bind(statement);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ List states = new ArrayList<>();
+ while (resultSet.next()) {
+ states.add(readState(resultSet));
+ }
+ return states;
+ }
+ } catch (SQLException e) {
+ throw failure(operation, e);
+ }
+ }
+
+ private void lockCorrelations(Connection connection, List correlatedStates) throws SQLException {
+ String previousProviderName = null;
+ String previousProviderMessageId = null;
+ for (MessageState state : correlatedStates) {
+ if (!state.getProviderName().equals(previousProviderName) ||
+ !state.getProviderMessageId().equals(previousProviderMessageId)) {
+ lockCorrelation(connection, state.getProviderName(), state.getProviderMessageId());
+ previousProviderName = state.getProviderName();
+ previousProviderMessageId = state.getProviderMessageId();
+ }
+ }
+ }
+
+ private void lockInitialMessageOwners(Connection connection, List> entries,
+ List correlatedStates) throws SQLException {
+ List messageIdsToLock = entries.stream().map(Map.Entry::getKey).collect(ArrayList::new,
+ ArrayList::add, ArrayList::addAll);
+ for (MessageState state : correlatedStates) {
+ findCorrelationOwner(connection, state.getProviderName(), state.getProviderMessageId())
+ .ifPresent(messageIdsToLock::add);
+ }
+ for (UUID messageId : messageIdsToLock.stream().distinct()
+ .sorted(Comparator.comparing(UUID::toString)).toList()) {
+ lockMessage(connection, messageId);
+ }
+ }
+
+ private boolean tryLinkProviderMessageId(UUID gatewayMessageId, String providerName, String providerMessageId) {
+ try (Connection connection = dataSource.getConnection()) {
+ connection.setAutoCommit(false);
+ try {
+ lockCorrelation(connection, providerName, providerMessageId);
+ Optional previousOwner = findCorrelationOwner(connection, providerName, providerMessageId);
+ List messageIdsToLock = new ArrayList<>();
+ messageIdsToLock.add(gatewayMessageId);
+ previousOwner.ifPresent(messageIdsToLock::add);
+ boolean targetFound = false;
+ for (UUID messageId : messageIdsToLock.stream().distinct()
+ .sorted(Comparator.comparing(UUID::toString)).toList()) {
+ boolean found = lockMessage(connection, messageId);
+ if (messageId.equals(gatewayMessageId)) {
+ targetFound = found;
+ }
+ }
+ if (!targetFound) {
+ connection.rollback();
+ return false;
+ }
+ saveCorrelation(connection, providerName, providerMessageId, gatewayMessageId);
+ if (!markAsSent(connection, gatewayMessageId, providerName, providerMessageId)) {
+ connection.rollback();
+ return false;
+ }
+ connection.commit();
+ return true;
+ } catch (SQLException e) {
+ rollback(connection, e);
+ throw e;
+ }
+ } catch (SQLException e) {
+ throw failure("link provider DLR ID", e);
+ }
+ }
+
+ private void lockCorrelation(Connection connection, String providerName,
+ String providerMessageId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(LOCK_CORRELATION_SQL)) {
+ statement.setString(1, providerName);
+ statement.setString(2, providerMessageId);
+ statement.execute();
+ }
+ }
+
+ private Optional findCorrelationOwner(Connection connection, String providerName,
+ String providerMessageId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(GET_CORRELATION_OWNER_SQL)) {
+ statement.setString(1, providerName);
+ statement.setString(2, providerMessageId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ return resultSet.next() ?
+ Optional.of(resultSet.getObject("gateway_message_id", UUID.class))
+ : Optional.empty();
+ }
+ }
+ }
+
+ private boolean lockMessage(Connection connection, UUID gatewayMsgId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(LOCK_MESSAGE_SQL)) {
+ statement.setObject(1, gatewayMsgId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ return resultSet.next();
+ }
+ }
+ }
+
+ private boolean markAsSent(Connection connection, UUID gatewayMessageId,
+ String providerName, String providerMessageId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(LINK_MESSAGE_SQL)) {
+ statement.setString(1, providerName);
+ statement.setString(2, providerMessageId);
+ statement.setObject(3, gatewayMessageId);
+ return statement.executeUpdate() == 1;
+ }
+ }
+
+ private Optional resolveState(Connection connection, UUID gatewayMessageId,
+ String providerName, String providerMessageId,
+ MessageState.MessageStatus status, int dlrState,
+ String errorCode) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(RESOLVE_STATE_SQL)) {
+ statement.setString(1, providerName);
+ statement.setString(2, providerMessageId);
+ statement.setString(3, status.name());
+ statement.setInt(4, dlrState);
+ statement.setString(5, errorCode);
+ statement.setObject(6, gatewayMessageId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ return resultSet.next() ? Optional.of(readState(resultSet)) : Optional.empty();
+ }
+ }
+ }
+
+ private void deleteCorrelations(Connection connection, UUID gatewayMessageId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(DELETE_CORRELATIONS_SQL)) {
+ statement.setObject(1, gatewayMessageId);
+ statement.executeUpdate();
+ }
+ }
+
+ private void deleteState(Connection connection, UUID gatewayMsgId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(DELETE_STATE_SQL)) {
+ statement.setObject(1, gatewayMsgId);
+ statement.executeUpdate();
+ }
+ }
+
+ private void saveCorrelation(Connection connection, String providerName,
+ String providerMessageId, UUID gatewayMessageId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(SAVE_CORRELATION_SQL)) {
+ statement.setString(1, providerName);
+ statement.setString(2, providerMessageId);
+ statement.setObject(3, gatewayMessageId);
+ statement.setString(4, providerName);
+ statement.setString(5, providerMessageId);
+ statement.setObject(6, gatewayMessageId);
+ statement.executeUpdate();
+ }
+ }
+
+ private void setStateParameters(Connection connection, PreparedStatement statement, UUID gatewayMsgId,
+ MessageState state) throws SQLException {
+ statement.setObject(1, gatewayMsgId);
+ statement.setString(2, state.getAccountId());
+ statement.setString(3, state.getSystemId());
+ statement.setString(4, state.getSourceAddr());
+ statement.setString(5, state.getDestAddr());
+ statement.setString(6, state.getProviderName());
+ statement.setString(7, state.getProviderMessageId());
+ statement.setString(8, state.getForwardDlrUrl());
+ setStringArray(connection, statement, 9, state.getReassembledParts());
+ statement.setString(10, state.getStatus().name());
+ if (state.getDlrState() == null) {
+ statement.setNull(11, Types.INTEGER);
+ } else {
+ statement.setInt(11, state.getDlrState());
+ }
+ statement.setString(12, state.getErrorCode());
+ statement.setString(13, state.getDeliveryChannel().name());
+ statement.setString(14, state.getDeliveryStatus().name());
+ statement.setInt(15, state.getDeliveryAttemptCount());
+ setTimestamp(statement, 16, state.getLastAttemptAt());
+ setTimestamp(statement, 17, state.getNextAttemptAt());
+ statement.setString(18, state.getLastDeliveryResult());
+ setTimestamp(statement, 19, state.getResolvedAt());
+ statement.setObject(20, toOffsetDateTime(state.getTimestamp()));
+ }
+
+ private MessageState readState(ResultSet resultSet) throws SQLException {
+ MessageState state = new MessageState(
+ resultSet.getObject("gateway_message_id", UUID.class).toString(),
+ resultSet.getString("account_id"),
+ resultSet.getString("system_id"),
+ resultSet.getString("source_address"),
+ resultSet.getString("destination_address"),
+ resultSet.getString("forward_dlr_url"));
+ state.setProviderName(resultSet.getString("provider_name"));
+ state.setProviderMessageId(resultSet.getString("provider_message_id"));
+ state.setReassembledParts(readStringArray(resultSet, "reassembled_parts"));
+ state.setStatus(MessageState.MessageStatus.valueOf(resultSet.getString("provider_status")));
+ state.setDlrState(resultSet.getObject("dlr_state", Integer.class));
+ state.setErrorCode(resultSet.getString("error_code"));
+ state.setDeliveryChannel(MessageState.DeliveryChannel.valueOf(resultSet.getString("delivery_channel")));
+ state.setDeliveryStatus(MessageState.DeliveryStatus.valueOf(resultSet.getString("delivery_status")));
+ state.setDeliveryAttemptCount(resultSet.getInt("delivery_attempt_count"));
+ state.setLastAttemptAt(readEpochMillis(resultSet, "last_attempt_at"));
+ state.setNextAttemptAt(readEpochMillis(resultSet, "next_attempt_at"));
+ state.setLastDeliveryResult(resultSet.getString("last_delivery_result"));
+ state.setResolvedAt(readEpochMillis(resultSet, "resolved_at"));
+ state.setTimestamp(readRequiredEpochMillis(resultSet, "updated_at"));
+ return state;
+ }
+
+ private void validateState(MessageState state) {
+ if (state.getStatus() == null || state.getDeliveryChannel() == null || state.getDeliveryStatus() == null) {
+ throw new IllegalArgumentException("DLR state statuses and delivery channel are required");
+ }
+ if (state.getDeliveryAttemptCount() < 0) {
+ throw new IllegalArgumentException("Delivery attempt count must not be negative");
+ }
+ if (state.getProviderName() == null && state.getProviderMessageId() == null) {
+ validateDeliveryTarget(state);
+ return;
+ }
+ if (state.getProviderName() == null || state.getProviderName().isBlank() ||
+ state.getProviderMessageId() == null || state.getProviderMessageId().isBlank()) {
+ throw new IllegalArgumentException(
+ "Provider name and provider message ID must either both be set or both be absent");
+ }
+ validateDeliveryTarget(state);
+ }
+
+ private void validateDeliveryTarget(MessageState state) {
+ if (state.getDeliveryChannel() == MessageState.DeliveryChannel.HTTP &&
+ (state.getForwardDlrUrl() == null || state.getForwardDlrUrl().isBlank())) {
+ throw new IllegalArgumentException("HTTP delivery requires a nonblank callback URL");
+ }
+ if (state.getDeliveryChannel() == MessageState.DeliveryChannel.SMPP &&
+ (state.getSystemId() == null || state.getSystemId().isBlank())) {
+ throw new IllegalArgumentException("SMPP delivery requires a nonblank system ID");
+ }
+ }
+
+ private boolean isLastCorrelationOwner(List> states, int index,
+ MessageState candidate) {
+ for (int laterIndex = index + 1; laterIndex < states.size(); laterIndex++) {
+ MessageState later = states.get(laterIndex).getValue();
+ if (candidate.getProviderName().equals(later.getProviderName()) &&
+ candidate.getProviderMessageId().equals(later.getProviderMessageId())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private void requireCorrelation(String providerName, String providerMessageId) {
+ if (providerName == null || providerName.isBlank() ||
+ providerMessageId == null || providerMessageId.isBlank()) {
+ throw new IllegalArgumentException("Provider name and provider message ID must not be blank");
+ }
+ }
+
+ private String normalizeResult(String result) {
+ return result == null || result.isBlank() ? null : result.trim();
+ }
+
+ private void setTimestamp(PreparedStatement statement, int index, Long epochMillis) throws SQLException {
+ if (epochMillis == null) {
+ statement.setNull(index, Types.TIMESTAMP_WITH_TIMEZONE);
+ } else {
+ statement.setObject(index, toOffsetDateTime(epochMillis));
+ }
+ }
+
+ private OffsetDateTime toOffsetDateTime(long epochMillis) {
+ return OffsetDateTime.ofInstant(Instant.ofEpochMilli(epochMillis), ZoneOffset.UTC);
+ }
+
+ private Long readEpochMillis(ResultSet resultSet, String columnName) throws SQLException {
+ OffsetDateTime value = resultSet.getObject(columnName, OffsetDateTime.class);
+ return value == null ? null : value.toInstant().toEpochMilli();
+ }
+
+ private long readRequiredEpochMillis(ResultSet resultSet, String columnName) throws SQLException {
+ return resultSet.getObject(columnName, OffsetDateTime.class).toInstant().toEpochMilli();
+ }
+
+ private List readStringArray(ResultSet resultSet, String columnName) throws SQLException {
+ Array array = resultSet.getArray(columnName);
+ if (array == null) {
+ return null;
+ }
+ return new ArrayList<>(Arrays.asList((String[]) array.getArray()));
+ }
+
+ private void setStringArray(Connection connection, PreparedStatement statement, int index,
+ List values) throws SQLException {
+ if (values == null) {
+ statement.setNull(index, Types.ARRAY);
+ return;
+ }
+ statement.setArray(index, connection.createArrayOf("text", values.toArray(String[]::new)));
+ }
+
+ private void sleepBeforeLinkRetry() {
+ try {
+ Thread.sleep(linkRetryIntervalMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new DlrStorageException("Interrupted while linking provider message ID", e);
+ }
+ }
+
+ private void checkExpiry() {
+ if (System.currentTimeMillis() - lastExpiryCheck < expiryCheckIntervalMillis ||
+ !expiryInProgress.compareAndSet(false, true)) {
+ return;
+ }
+ try {
+ if (System.currentTimeMillis() - lastExpiryCheck >= expiryCheckIntervalMillis) {
+ deleteExpiredState();
+ }
+ } catch (RuntimeException e) {
+ logger.warn("DLR retention cleanup failed; retrying after the next interval");
+ } finally {
+ lastExpiryCheck = System.currentTimeMillis();
+ expiryInProgress.set(false);
+ }
+ }
+
+ private void deleteExpiredState() {
+ try (Connection connection = dataSource.getConnection()) {
+ connection.setAutoCommit(false);
+ try (PreparedStatement messages = connection.prepareStatement(DELETE_EXPIRED_MESSAGES_SQL);
+ PreparedStatement correlations = connection.prepareStatement(DELETE_EXPIRED_CORRELATIONS_SQL)) {
+ messages.executeUpdate();
+ correlations.executeUpdate();
+ connection.commit();
+ } catch (SQLException e) {
+ rollback(connection, e);
+ throw e;
+ }
+ } catch (SQLException e) {
+ throw failure("expire DLR state", e);
+ }
+ }
+
+ private UUID parseGatewayId(String gatewayMsgId) {
+ try {
+ return UUID.fromString(gatewayMsgId);
+ } catch (IllegalArgumentException | NullPointerException e) {
+ throw new DlrStorageException("Invalid gateway message ID", e);
+ }
+ }
+
+ private void rollback(Connection connection, SQLException failure) {
+ try {
+ connection.rollback();
+ } catch (SQLException rollbackFailure) {
+ failure.addSuppressed(rollbackFailure);
+ }
+ }
+
+ private DlrStorageException failure(String operation, SQLException cause) {
+ logger.error("Failed to {}: sqlState={} errorCode={} reason={}",
+ operation, cause.getSQLState(), cause.getErrorCode(), cause.getMessage());
+ logger.debug("DLR storage failure details while attempting to {}", operation, cause);
+ return new DlrStorageException("Failed to " + operation, cause);
+ }
+
+ @FunctionalInterface
+ private interface StatementBinder {
+ void bind(PreparedStatement statement) throws SQLException;
+ }
+}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/InMemoryMessageTracker.java b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/StandardMessageTracker.java
similarity index 56%
rename from sendium-core/src/main/java/gr/cytech/sendium/core/worker/InMemoryMessageTracker.java
rename to sendium-core/src/main/java/gr/cytech/sendium/core/worker/StandardMessageTracker.java
index 18d5eee..5949308 100644
--- a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/InMemoryMessageTracker.java
+++ b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/StandardMessageTracker.java
@@ -11,26 +11,24 @@
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Optional;
-import java.util.concurrent.ConcurrentLinkedQueue;
-public class InMemoryMessageTracker implements Tracker {
+public class StandardMessageTracker implements Tracker {
- private static final Logger logger = LoggerFactory.getLogger(InMemoryMessageTracker.class);
+ private static final Logger logger = LoggerFactory.getLogger(StandardMessageTracker.class);
AbstractOutWorker outWorker;
- private final ConcurrentLinkedQueue dlrQueue = new ConcurrentLinkedQueue<>();
- public InMemoryMessageTracker(AbstractOutWorker worker) {
+ public StandardMessageTracker(AbstractOutWorker worker) {
this.outWorker = worker;
}
@Override
public void init() {
- logger.info("InMemoryMessageTracker initialized");
+ logger.info("StandardMessageTracker initialized");
}
@Override
public boolean stop() {
- logger.info("InMemoryMessageTracker stopping");
+ logger.info("StandardMessageTracker stopping");
return true;
}
@@ -43,17 +41,27 @@ public void configure(String key, String newValue, String oldValue) {
}
@Override
- public int updateSendStatusAndExtID(String smsid, StandardMessage pMsg, String smscid) {
- smsid = pMsg.serial; //in our case no hash needed
- if (smsid != null && !smsid.isEmpty() && smscid != null && !smscid.isEmpty()) {
- outWorker.getWorkerResources().getDlrService().linkOperatorId(smsid, smscid);
- if (MessageTrace.shouldLog(outWorker.getConfigurationProvider(), MessageTrace.EVENT_OPERATOR_LINKED)) {
- logger.info("message.operator.linked operatorMsgId={} {}", MessageTrace.value(smscid), MessageTrace.identifiers(pMsg));
- }
- return 1;
+ public int updateSendStatusAndExtID(String hashedProviderMessageId, StandardMessage message,
+ String providerMessageId) {
+ String gatewayMessageId = message.serial;
+ String providerName = outWorker.getDlrProviderName();
+ if (gatewayMessageId == null || gatewayMessageId.isBlank() ||
+ providerName == null || providerName.isBlank() ||
+ providerMessageId == null || providerMessageId.isBlank()) {
+ logger.warn("Invalid DLR correlation identifiers");
+ return 0;
}
- logger.warn("Invalid parameters: smsid={}, smscid={}", smsid, smscid);
- return 0;
+ if (!outWorker.getWorkerResources().isDlrPersistenceEnabled()) {
+ return 0;
+ }
+
+ outWorker.getWorkerResources().getDlrService()
+ .linkProviderMessageId(gatewayMessageId, providerName, providerMessageId);
+ if (MessageTrace.shouldLog(outWorker.getConfigurationProvider(), MessageTrace.EVENT_PROVIDER_LINKED)) {
+ logger.info("message.provider.linked providerMessageId={} {}", MessageTrace.value(providerMessageId),
+ MessageTrace.identifiers(message));
+ }
+ return 1;
}
@Override
@@ -71,12 +79,21 @@ public String getVendorPriceGateway() {
}
@Override
- public void createAndEnqueueDLR(int mqid, String smscid, String smsid, String from, String to,
+ public void createAndEnqueueDLR(int mqid, String providerMessageId, String hashedProviderMessageId,
+ String from, String to,
String body, int state, String errorCode, HashMap tlvs) {
- Optional optState = outWorker.getWorkerResources().getDlrService().resolveAndRemoveDlr(smscid, state);
+ if (!outWorker.getWorkerResources().isDlrPersistenceEnabled()) {
+ return;
+ }
+
+ Optional optState = outWorker.getWorkerResources().getDlrService()
+ .resolveDlr(outWorker.getDlrProviderName(), providerMessageId, state, errorCode);
if (optState.isPresent()) {
MessageState msgState = optState.get();
+ if (msgState.getDeliveryChannel() != MessageState.DeliveryChannel.SMPP) {
+ return;
+ }
StandardMessage dlrMsg = new StandardMessage();
dlrMsg.serial = msgState.getGatewayMsgId();
@@ -96,10 +113,11 @@ public void createAndEnqueueDLR(int mqid, String smscid, String smsid, String fr
outWorker.handleException(ie);
}
if (MessageTrace.shouldLog(outWorker.getConfigurationProvider(), MessageTrace.EVENT_DLR)) {
- logger.info("message.dlr status={} operatorMsgId={} {}", state, MessageTrace.value(smscid), MessageTrace.identifiers(dlrMsg));
+ logger.info("message.dlr status={} providerMessageId={} {}", state,
+ MessageTrace.value(providerMessageId), MessageTrace.identifiers(dlrMsg));
}
} else {
- logger.warn("DLR received for unknown/expired message: smsid={}", smsid);
+ logger.warn("DLR received for unknown/expired provider message ID");
}
}
@@ -108,11 +126,4 @@ public int getConfiguredMccMnc() {
return 0;
}
- public StandardMessage pollDlr() {
- return dlrQueue.poll();
- }
-
- public int getDlrQueueSize() {
- return dlrQueue.size();
- }
}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/Tracker.java b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/Tracker.java
index bbf86d4..c1c6bec 100644
--- a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/Tracker.java
+++ b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/Tracker.java
@@ -12,14 +12,21 @@ public interface Tracker {
void configure(String key, String newValue, String oldValue);
- int updateSendStatusAndExtID(String smsid, M pMsg, String smscid);
+ /**
+ * @param hashedProviderMessageId precomputed hash retained for tracker implementations that use hashed indexes
+ */
+ int updateSendStatusAndExtID(String hashedProviderMessageId, M message, String providerMessageId);
String getHashedMessageID(String messageId);
String getVendorPriceGateway();
- void createAndEnqueueDLR(int mqid, String smscid, String smsid, String from, String to, String body,
- int state, String errorCode, HashMap tlvs);
+ /**
+ * @param hashedProviderMessageId precomputed hash retained for tracker implementations that use hashed indexes
+ */
+ void createAndEnqueueDLR(int mqid, String providerMessageId, String hashedProviderMessageId,
+ String from, String to, String body, int state, String errorCode,
+ HashMap tlvs);
int getConfiguredMccMnc();
}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/UnpushedDlr.java b/sendium-core/src/main/java/gr/cytech/sendium/core/worker/UnpushedDlr.java
deleted file mode 100644
index b9e3d6d..0000000
--- a/sendium-core/src/main/java/gr/cytech/sendium/core/worker/UnpushedDlr.java
+++ /dev/null
@@ -1,61 +0,0 @@
-package gr.cytech.sendium.core.worker;
-
-import gr.cytech.sendium.core.message.StandardMessage;
-import io.quarkus.runtime.annotations.RegisterForReflection;
-
-import java.io.Serializable;
-import java.util.ArrayList;
-import java.util.List;
-
-@RegisterForReflection
-public class UnpushedDlr implements Serializable {
- private static final long serialVersionUID = 1L;
-
- public String systemId;
- public String accountId;
- public String from;
- public String to;
- public String serial;
- public int msgId;
- public int state;
- public String errcode;
- public boolean acked;
- public int priority;
- public List reassembledParts;
-
- public UnpushedDlr() {
- }
-
- public static UnpushedDlr fromMessage(StandardMessage msg) {
- UnpushedDlr dlr = new UnpushedDlr();
- dlr.systemId = msg.systemId;
- dlr.accountId = msg.owner_id;
- dlr.from = msg.from;
- dlr.to = msg.to;
- dlr.serial = msg.serial;
- dlr.msgId = msg.msgId;
- dlr.state = msg.state;
- dlr.errcode = msg.errcode;
- dlr.acked = msg.acked;
- dlr.priority = msg.priority;
- dlr.reassembledParts = msg.reassembledParts == null ? null : new ArrayList<>(msg.reassembledParts);
- return dlr;
- }
-
- public StandardMessage toMessage() {
- StandardMessage msg = new StandardMessage();
- msg.type = StandardMessage.MSG_DLR;
- msg.systemId = systemId;
- msg.owner_id = accountId;
- msg.from = from;
- msg.to = to;
- msg.serial = serial;
- msg.msgId = msgId;
- msg.state = state;
- msg.errcode = errcode;
- msg.acked = acked;
- msg.priority = priority;
- msg.reassembledParts = reassembledParts == null ? null : new ArrayList<>(reassembledParts);
- return msg;
- }
-}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/external/WorkerResourceProvider.java b/sendium-core/src/main/java/gr/cytech/sendium/external/WorkerResourceProvider.java
index 71fcd3f..29805fa 100644
--- a/sendium-core/src/main/java/gr/cytech/sendium/external/WorkerResourceProvider.java
+++ b/sendium-core/src/main/java/gr/cytech/sendium/external/WorkerResourceProvider.java
@@ -4,10 +4,11 @@
import gr.cytech.sendium.core.queue.InMemoryQueueProvider;
import gr.cytech.sendium.core.queue.QueueProvider;
import gr.cytech.sendium.core.smpp.client.SmppClientHolder;
+import gr.cytech.sendium.core.worker.DlrService;
import gr.cytech.sendium.core.worker.ForwardMoService;
-import gr.cytech.sendium.core.worker.InMemoryDlrService;
import io.quarkus.arc.DefaultBean;
import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -25,7 +26,7 @@ public enum Visibility { INTERNAL, EXTERNAL }
@Inject InMemoryQueueProvider queueProvider;
@Inject CredentialFileWatcher credentialFileWatcher;
- @Inject InMemoryDlrService dlrService;
+ @Inject Instance dlrServices;
@Inject ForwardMoService forwardMoService;
@Inject SmppClientHolder smppClientHolder;
@@ -40,8 +41,20 @@ public CredentialFileWatcher getCredentialFileWatcher() {
return credentialFileWatcher;
}
- public InMemoryDlrService getDlrService() {
- return dlrService;
+ /**
+ * Whether Sendium owns the DLR persistence lifecycle in this build. Callers on message paths must check this
+ * before {@link #getDlrService()}: an application embedding {@code sendium-core} can leave
+ * {@code sendium.dlr.persistence.enabled} unset and take over DLR tracking itself.
+ */
+ public boolean isDlrPersistenceEnabled() {
+ return !dlrServices.isUnsatisfied();
+ }
+
+ public DlrService getDlrService() {
+ if (dlrServices.isUnsatisfied()) {
+ throw new IllegalStateException("Sendium DLR persistence is disabled");
+ }
+ return dlrServices.get();
}
public ForwardMoService getForwardMoService() {
@@ -90,4 +103,4 @@ public boolean stopExecutor(ExecutorService executor, Logger errorLogger, String
}
return false;
}
-}
\ No newline at end of file
+}
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/routing/StandardOutgoingWorkerHandler.java b/sendium-core/src/main/java/gr/cytech/sendium/routing/StandardOutgoingWorkerHandler.java
index 9bd70e3..3648b4b 100644
--- a/sendium-core/src/main/java/gr/cytech/sendium/routing/StandardOutgoingWorkerHandler.java
+++ b/sendium-core/src/main/java/gr/cytech/sendium/routing/StandardOutgoingWorkerHandler.java
@@ -8,9 +8,9 @@
import gr.cytech.sendium.core.AbstractOutWorker;
import gr.cytech.sendium.core.message.StandardMessage;
import gr.cytech.sendium.core.queue.InMemoryQueueProvider;
-import gr.cytech.sendium.core.smpp.server.InMemorySmppServerMessageStore;
import gr.cytech.sendium.core.smpp.server.SmppServerWorker;
-import gr.cytech.sendium.core.worker.InMemoryMessageTracker;
+import gr.cytech.sendium.core.smpp.server.StandardSmppServerMessageStore;
+import gr.cytech.sendium.core.worker.StandardMessageTracker;
import gr.cytech.sendium.core.worker.WorkerType;
import gr.cytech.sendium.external.WorkerResourceProvider;
import gr.cytech.sendium.external.filter.InMessageFiltering;
@@ -203,10 +203,10 @@ protected AbstractOutWorker startWorker(String instName, String
}
worker = selectedWorker.get();
worker.setupInstance(configurationHandler, instName, queueProvider.getRouterQueue());
- worker.init(workerResourceProvider, new InMemoryMessageTracker(worker));
+ worker.init(workerResourceProvider, new StandardMessageTracker(worker));
if (SmppServerWorker.TYPE_SMPP_SERVER.equals(worker.getType())) {
var smppServer = (SmppServerWorker) worker;
- smppServer.setMessageStore(new InMemorySmppServerMessageStore(smppServer));
+ smppServer.setMessageStore(new StandardSmppServerMessageStore(smppServer));
}
} catch (Exception e) {
logger.error("Could not start worker {} {}", workerTypeString, instName, e);
diff --git a/sendium-core/src/main/java/gr/cytech/sendium/util/MessageTrace.java b/sendium-core/src/main/java/gr/cytech/sendium/util/MessageTrace.java
index f79353e..3eae6e6 100644
--- a/sendium-core/src/main/java/gr/cytech/sendium/util/MessageTrace.java
+++ b/sendium-core/src/main/java/gr/cytech/sendium/util/MessageTrace.java
@@ -17,7 +17,7 @@ public final class MessageTrace {
public static final String EVENT_DELIVERY_FAILED = "message.delivery.failed";
public static final String EVENT_DELIVERY_RETRY = "message.delivery.retry";
public static final String EVENT_ENQUEUED = "message.enqueued";
- public static final String EVENT_OPERATOR_LINKED = "message.operator.linked";
+ public static final String EVENT_PROVIDER_LINKED = "message.provider.linked";
public static final String EVENT_ROUTED = "message.routed";
public static final String EVENT_ROUTING_MISS = "message.routing.miss";
public static final String EVENT_SUBMITTED = "message.submitted";
diff --git a/sendium-core/src/main/resources/application.properties b/sendium-core/src/main/resources/application.properties
index 014b868..abddac9 100644
--- a/sendium-core/src/main/resources/application.properties
+++ b/sendium-core/src/main/resources/application.properties
@@ -1,3 +1,24 @@
%test.smsg.routing.file.path=src/test/resources/routingTable.conf
%test.smsg.properties.file.path=src/test/resources/smsg.properties
-%test.smsg.credentials.file.path=src/test/resources/credentials.yml
\ No newline at end of file
+%test.smsg.credentials.file.path=src/test/resources/credentials.yml
+
+# Sendium-owned DLR persistence is opt-in and is enabled by the standalone application.
+# The flag is deliberately left undefined here: the DLR beans use enableIfMissing=false, so an embedding
+# application enables the subsystem by declaring the property itself rather than by overriding this file.
+quarkus.datasource.devservices.enabled=false
+quarkus.datasource.dlr.db-kind=postgresql
+quarkus.datasource.dlr.active=${sendium.dlr.persistence.enabled:false}
+quarkus.datasource.dlr.devservices.enabled=false
+quarkus.datasource.dlr.jdbc.min-size=0
+quarkus.datasource.dlr.jdbc.max-size=10
+quarkus.datasource.dlr.jdbc.acquisition-timeout=5S
+quarkus.datasource.metrics.enabled=true
+quarkus.flyway.dlr.locations=db/sendium-dlr/postgresql
+quarkus.flyway.dlr.active=${quarkus.datasource.dlr.active}
+quarkus.flyway.dlr.migrate-at-start=${quarkus.datasource.dlr.active}
+quarkus.flyway.dlr.validate-on-migrate=true
+quarkus.flyway.dlr.clean-disabled=true
+quarkus.flyway.dlr.schemas=sendium_dlr
+quarkus.flyway.dlr.default-schema=sendium_dlr
+quarkus.flyway.dlr.table=flyway_schema_history
+quarkus.flyway.dlr.create-schemas=true
diff --git a/sendium-core/src/main/resources/db/sendium-dlr/postgresql/V1__create_sendium_dlr_schema.sql b/sendium-core/src/main/resources/db/sendium-dlr/postgresql/V1__create_sendium_dlr_schema.sql
new file mode 100644
index 0000000..8b011db
--- /dev/null
+++ b/sendium-core/src/main/resources/db/sendium-dlr/postgresql/V1__create_sendium_dlr_schema.sql
@@ -0,0 +1,82 @@
+CREATE SCHEMA IF NOT EXISTS sendium_dlr;
+
+CREATE TABLE sendium_dlr.dlr_message (
+ gateway_message_id UUID PRIMARY KEY,
+ account_id TEXT,
+ system_id TEXT,
+ source_address TEXT,
+ destination_address TEXT,
+ provider_name TEXT,
+ provider_message_id TEXT,
+ forward_dlr_url TEXT,
+ reassembled_parts TEXT[],
+ provider_status TEXT NOT NULL,
+ dlr_state INTEGER,
+ error_code TEXT,
+ delivery_channel TEXT NOT NULL DEFAULT 'NONE',
+ delivery_status TEXT NOT NULL DEFAULT 'WAITING_PROVIDER',
+ delivery_attempt_count INTEGER NOT NULL DEFAULT 0,
+ last_attempt_at TIMESTAMPTZ,
+ next_attempt_at TIMESTAMPTZ,
+ last_delivery_result TEXT,
+ resolved_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT dlr_message_provider_pair_check
+ CHECK ((provider_name IS NULL) = (provider_message_id IS NULL)),
+ CONSTRAINT dlr_message_provider_name_not_blank
+ CHECK (provider_name IS NULL OR provider_name !~ '^[[:space:]]*$'),
+ CONSTRAINT dlr_message_provider_message_id_not_blank
+ CHECK (provider_message_id IS NULL OR provider_message_id !~ '^[[:space:]]*$'),
+ CONSTRAINT dlr_message_provider_status_check
+ CHECK (provider_status IN ('ACCEPTED', 'SENT', 'DELIVERED', 'FAILED')),
+ CONSTRAINT dlr_message_delivery_channel_check
+ CHECK (delivery_channel IN ('NONE', 'HTTP', 'SMPP')),
+ CONSTRAINT dlr_message_delivery_status_check
+ CHECK (delivery_status IN ('WAITING_PROVIDER', 'PENDING', 'FAILED')),
+ CONSTRAINT dlr_message_delivery_attempt_count_check
+ CHECK (delivery_attempt_count >= 0),
+ CONSTRAINT dlr_message_http_url_check
+ CHECK (delivery_channel <> 'HTTP' OR
+ (forward_dlr_url IS NOT NULL AND forward_dlr_url !~ '^[[:space:]]*$')),
+ CONSTRAINT dlr_message_smpp_system_id_check
+ CHECK (delivery_channel <> 'SMPP' OR
+ (system_id IS NOT NULL AND system_id !~ '^[[:space:]]*$'))
+);
+
+CREATE INDEX dlr_message_created_at_idx
+ ON sendium_dlr.dlr_message (created_at);
+
+CREATE INDEX dlr_message_provider_message_id_idx
+ ON sendium_dlr.dlr_message (provider_name, provider_message_id)
+ WHERE provider_message_id IS NOT NULL;
+
+CREATE INDEX dlr_message_http_due_idx
+ ON sendium_dlr.dlr_message (next_attempt_at)
+ WHERE delivery_channel = 'HTTP' AND delivery_status = 'PENDING';
+
+CREATE INDEX dlr_message_smpp_replay_idx
+ ON sendium_dlr.dlr_message (system_id, resolved_at)
+ WHERE delivery_channel = 'SMPP' AND delivery_status = 'PENDING';
+
+CREATE TABLE sendium_dlr.provider_correlation (
+ provider_name TEXT NOT NULL,
+ provider_message_id TEXT NOT NULL,
+ gateway_message_id UUID NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (provider_name, provider_message_id),
+ CONSTRAINT provider_correlation_provider_name_not_blank
+ CHECK (provider_name !~ '^[[:space:]]*$'),
+ CONSTRAINT provider_correlation_provider_message_id_not_blank
+ CHECK (provider_message_id !~ '^[[:space:]]*$'),
+ CONSTRAINT provider_correlation_message_fk
+ FOREIGN KEY (gateway_message_id)
+ REFERENCES sendium_dlr.dlr_message (gateway_message_id)
+ ON DELETE CASCADE
+);
+
+CREATE INDEX provider_correlation_created_at_idx
+ ON sendium_dlr.provider_correlation (created_at);
+
+CREATE INDEX provider_correlation_gateway_message_idx
+ ON sendium_dlr.provider_correlation (gateway_message_id);
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/dlr/PostgresqlMigrationIT.java b/sendium-core/src/test/java/gr/cytech/sendium/core/dlr/PostgresqlMigrationIT.java
new file mode 100644
index 0000000..bf39f30
--- /dev/null
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/dlr/PostgresqlMigrationIT.java
@@ -0,0 +1,253 @@
+package gr.cytech.sendium.core.dlr;
+
+import org.flywaydb.core.Flyway;
+import org.flywaydb.core.api.output.MigrateResult;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.postgresql.PostgreSQLContainer;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PostgresqlMigrationIT {
+ private static final String MIGRATION_LOCATION = "classpath:db/sendium-dlr/postgresql";
+ private static final PostgreSQLContainer POSTGRESQL = new PostgreSQLContainer("postgres:17-alpine")
+ .withDatabaseName("sendium")
+ .withUsername("sendium")
+ .withPassword("sendium-test");
+
+ private static Flyway flyway;
+ private static MigrateResult initialMigration;
+
+ @BeforeAll
+ static void migrateSchema() {
+ POSTGRESQL.start();
+ flyway = Flyway.configure()
+ .dataSource(POSTGRESQL.getJdbcUrl(), POSTGRESQL.getUsername(), POSTGRESQL.getPassword())
+ .locations(MIGRATION_LOCATION)
+ .load();
+ initialMigration = flyway.migrate();
+ }
+
+ @AfterAll
+ static void stopPostgresql() {
+ POSTGRESQL.stop();
+ }
+
+ @Test
+ void migrationCreatesDlrMessageSchemaAndPartialIndexes() throws SQLException {
+ assertThat(initialMigration.success).isTrue();
+ assertThat(initialMigration.migrationsExecuted).isOne();
+
+ try (Connection connection = connection()) {
+ assertThat(loadNames(connection,
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = 'sendium_dlr'"))
+ .containsExactlyInAnyOrder("dlr_message", "provider_correlation");
+ assertThat(loadNames(connection,
+ "SELECT indexname FROM pg_indexes WHERE schemaname = 'sendium_dlr'"))
+ .contains("dlr_message_created_at_idx",
+ "dlr_message_provider_message_id_idx",
+ "dlr_message_http_due_idx",
+ "dlr_message_smpp_replay_idx",
+ "provider_correlation_created_at_idx",
+ "provider_correlation_gateway_message_idx");
+ assertThat(loadIndexDefinition(connection, "dlr_message_http_due_idx"))
+ .contains("next_attempt_at")
+ .contains("delivery_channel = 'HTTP'")
+ .contains("delivery_status = 'PENDING'");
+ assertThat(loadIndexDefinition(connection, "dlr_message_smpp_replay_idx"))
+ .contains("system_id", "resolved_at")
+ .contains("delivery_channel = 'SMPP'")
+ .contains("delivery_status = 'PENDING'");
+ assertThat(loadColumnType(connection, "dlr_message", "gateway_message_id")).isEqualTo("uuid");
+ assertThat(loadColumnNames(connection, "dlr_message"))
+ .contains("dlr_state", "error_code", "delivery_channel", "delivery_status",
+ "delivery_attempt_count", "last_attempt_at", "next_attempt_at",
+ "last_delivery_result", "resolved_at")
+ .doesNotContain("generation_id");
+ }
+ }
+
+ @Test
+ void migrationIsIdempotent() {
+ MigrateResult repeatedMigration = flyway.migrate();
+
+ assertThat(repeatedMigration.success).isTrue();
+ assertThat(repeatedMigration.migrationsExecuted).isZero();
+ }
+
+ @Test
+ void schemaRejectsInvalidProviderAndDeliveryStates() throws SQLException {
+ try (Connection connection = connection()) {
+ assertInvalidMessage(connection, "UNKNOWN", "NONE", "WAITING_PROVIDER", 0, null, null);
+ assertInvalidMessage(connection, "ACCEPTED", "MAIL", "WAITING_PROVIDER", 0, null, null);
+ assertInvalidMessage(connection, "ACCEPTED", "NONE", "DONE", 0, null, null);
+ assertInvalidMessage(connection, "ACCEPTED", "NONE", "WAITING_PROVIDER", -1, null, null);
+ }
+ }
+
+ @Test
+ void schemaRequiresValidChannelTargets() throws SQLException {
+ try (Connection connection = connection()) {
+ for (String blank : List.of("", " ", "\t\n")) {
+ assertInvalidMessage(connection, "ACCEPTED", "HTTP", "WAITING_PROVIDER", 0, "system", blank);
+ assertInvalidMessage(connection, "ACCEPTED", "SMPP", "WAITING_PROVIDER", 0, blank,
+ "https://example.test/dlr");
+ }
+ assertInvalidMessage(connection, "ACCEPTED", "HTTP", "WAITING_PROVIDER", 0, "system", null);
+ assertInvalidMessage(connection, "ACCEPTED", "SMPP", "WAITING_PROVIDER", 0, null,
+ "https://example.test/dlr");
+ }
+ }
+
+ @Test
+ void providerCorrelationReferencesDlrMessageAndCascades() throws SQLException {
+ UUID gatewayId = UUID.randomUUID();
+ try (Connection connection = connection()) {
+ insertMessage(connection, gatewayId);
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO sendium_dlr.provider_correlation
+ (provider_name, provider_message_id, gateway_message_id)
+ VALUES ('provider', 'message', ?)
+ """)) {
+ statement.setObject(1, gatewayId);
+ statement.executeUpdate();
+ }
+ try (PreparedStatement statement = connection.prepareStatement(
+ "DELETE FROM sendium_dlr.dlr_message WHERE gateway_message_id = ?")) {
+ statement.setObject(1, gatewayId);
+ statement.executeUpdate();
+ }
+ assertThat(loadCount(connection, "sendium_dlr.provider_correlation")).isZero();
+ }
+ }
+
+ @Test
+ void defaultsWaitingProviderWithNoAttempts() throws SQLException {
+ UUID gatewayId = UUID.randomUUID();
+ try (Connection connection = connection()) {
+ insertMessage(connection, gatewayId);
+ try (PreparedStatement statement = connection.prepareStatement("""
+ SELECT delivery_channel, delivery_status, delivery_attempt_count
+ FROM sendium_dlr.dlr_message WHERE gateway_message_id = ?
+ """)) {
+ statement.setObject(1, gatewayId);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertThat(resultSet.next()).isTrue();
+ assertThat(resultSet.getString("delivery_channel")).isEqualTo("NONE");
+ assertThat(resultSet.getString("delivery_status")).isEqualTo("WAITING_PROVIDER");
+ assertThat(resultSet.getInt("delivery_attempt_count")).isZero();
+ }
+ }
+ }
+ }
+
+ private static void assertInvalidMessage(Connection connection, String providerStatus, String channel,
+ String deliveryStatus, int attempts, String systemId,
+ String callbackUrl) {
+ assertThatThrownBy(() -> {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO sendium_dlr.dlr_message
+ (gateway_message_id, provider_status, delivery_channel, delivery_status,
+ delivery_attempt_count, system_id, forward_dlr_url)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """)) {
+ statement.setObject(1, UUID.randomUUID());
+ statement.setString(2, providerStatus);
+ statement.setString(3, channel);
+ statement.setString(4, deliveryStatus);
+ statement.setInt(5, attempts);
+ statement.setString(6, systemId);
+ statement.setString(7, callbackUrl);
+ statement.executeUpdate();
+ }
+ }).isInstanceOf(SQLException.class);
+ }
+
+ private static void insertMessage(Connection connection, UUID gatewayId) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ INSERT INTO sendium_dlr.dlr_message (gateway_message_id, provider_status)
+ VALUES (?, 'ACCEPTED')
+ """)) {
+ statement.setObject(1, gatewayId);
+ statement.executeUpdate();
+ }
+ }
+
+ private static Connection connection() throws SQLException {
+ return POSTGRESQL.createConnection("");
+ }
+
+ private static Set loadNames(Connection connection, String sql) throws SQLException {
+ Set names = new HashSet<>();
+ try (Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery(sql)) {
+ while (resultSet.next()) {
+ names.add(resultSet.getString(1));
+ }
+ }
+ return names;
+ }
+
+ private static Set loadColumnNames(Connection connection, String tableName) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ SELECT column_name FROM information_schema.columns
+ WHERE table_schema = 'sendium_dlr' AND table_name = ?
+ """)) {
+ statement.setString(1, tableName);
+ Set names = new HashSet<>();
+ try (ResultSet resultSet = statement.executeQuery()) {
+ while (resultSet.next()) {
+ names.add(resultSet.getString(1));
+ }
+ }
+ return names;
+ }
+ }
+
+ private static String loadColumnType(Connection connection, String tableName,
+ String columnName) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ SELECT data_type FROM information_schema.columns
+ WHERE table_schema = 'sendium_dlr' AND table_name = ? AND column_name = ?
+ """)) {
+ statement.setString(1, tableName);
+ statement.setString(2, columnName);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertThat(resultSet.next()).isTrue();
+ return resultSet.getString(1);
+ }
+ }
+ }
+
+ private static String loadIndexDefinition(Connection connection, String indexName) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement("""
+ SELECT indexdef FROM pg_indexes WHERE schemaname = 'sendium_dlr' AND indexname = ?
+ """)) {
+ statement.setString(1, indexName);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ assertThat(resultSet.next()).isTrue();
+ return resultSet.getString(1);
+ }
+ }
+ }
+
+ private static int loadCount(Connection connection, String table) throws SQLException {
+ try (Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM " + table)) {
+ resultSet.next();
+ return resultSet.getInt(1);
+ }
+ }
+}
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceIT.java b/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceIT.java
index 634d735..5c03026 100644
--- a/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceIT.java
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceIT.java
@@ -2,10 +2,12 @@
import gr.cytech.sendium.core.message.StandardMessage;
import gr.cytech.sendium.core.queue.Queue;
-import gr.cytech.sendium.core.worker.InMemoryDlrService;
+import gr.cytech.sendium.core.worker.DlrService;
import gr.cytech.sendium.core.worker.MessageState;
+import gr.cytech.sendium.core.worker.PostgresqlDlrQuarkusTestResource;
import gr.cytech.sendium.routing.OutgoingWorkerManager;
import gr.cytech.sendium.routing.StandardOutgoingWorkerHandler;
+import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.enterprise.inject.spi.CDI;
import org.junit.jupiter.api.BeforeAll;
@@ -22,9 +24,10 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
@QuarkusTest
+@QuarkusTestResource(value = PostgresqlDlrQuarkusTestResource.class, restrictToAnnotatedClass = true)
class KannelResourceIT {
static StandardOutgoingWorkerHandler outgoingWorkerHandler;
- static InMemoryDlrService dlrService;
+ static DlrService dlrService;
CaptorWorker captorWorker;
private final String usernamekannel = "test2";
@@ -34,7 +37,7 @@ class KannelResourceIT {
@BeforeAll
static void beforeAll() {
outgoingWorkerHandler = (StandardOutgoingWorkerHandler) CDI.current().select(OutgoingWorkerManager.class).get();
- dlrService = CDI.current().select(InMemoryDlrService.class).get();
+ dlrService = CDI.current().select(DlrService.class).get();
}
@BeforeEach
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceTest.java
new file mode 100644
index 0000000..b7f24b9
--- /dev/null
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/http/KannelResourceTest.java
@@ -0,0 +1,131 @@
+package gr.cytech.sendium.core.http;
+
+import gr.cytech.sendium.auth.CredentialFileWatcher;
+import gr.cytech.sendium.conf.SendiumConfigurationHandler;
+import gr.cytech.sendium.core.message.StandardMessage;
+import gr.cytech.sendium.core.queue.InMemoryQueueProvider;
+import gr.cytech.sendium.core.queue.Queue;
+import gr.cytech.sendium.core.worker.DlrService;
+import gr.cytech.sendium.core.worker.DlrStorageException;
+import gr.cytech.sendium.core.worker.MessageState;
+import jakarta.enterprise.inject.Instance;
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+
+import java.util.Map;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class KannelResourceTest {
+ private static final String USERNAME = "http-user";
+ private static final String PASSWORD = "secret";
+
+ private Queue routerQueue;
+ private DlrService dlrService;
+ private Instance dlrServices;
+ private KannelResource resource;
+
+ @BeforeEach
+ @SuppressWarnings("unchecked")
+ void setUp() {
+ InMemoryQueueProvider queueProvider = mock(InMemoryQueueProvider.class);
+ routerQueue = mock(Queue.class);
+ when(queueProvider.getRouterQueue()).thenReturn(routerQueue);
+
+ CredentialFileWatcher credentials = mock(CredentialFileWatcher.class);
+ CredentialFileWatcher.Credential credential = new CredentialFileWatcher.Credential(
+ CredentialFileWatcher.CredentialType.HTTP, null, null, USERNAME, PASSWORD, null, Set.of());
+ when(credentials.getValidCredentials()).thenReturn(Map.of(USERNAME, credential));
+
+ resource = new KannelResource();
+ resource.queueProvider = queueProvider;
+ resource.credentialFileWatcher = credentials;
+ resource.configurationHandler = mock(SendiumConfigurationHandler.class);
+ dlrService = mock(DlrService.class);
+ dlrServices = mock(Instance.class);
+ when(dlrServices.get()).thenReturn(dlrService);
+ resource.dlrServices = dlrServices;
+ }
+
+ @Test
+ void persistsStateBeforeQueueAdmissionForEverySubmission() throws InterruptedException {
+ ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(MessageState.class);
+ ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(StandardMessage.class);
+ InOrder order = inOrder(dlrService, routerQueue);
+
+ Response response = submit(null);
+
+ order.verify(dlrService).saveInitialState(stateCaptor.capture());
+ order.verify(routerQueue).enqueue(messageCaptor.capture());
+ MessageState state = stateCaptor.getValue();
+ StandardMessage message = messageCaptor.getValue();
+ assertThat(response.getStatus()).isEqualTo(Response.Status.ACCEPTED.getStatusCode());
+ assertThat(response.getEntity()).isEqualTo(message.serial).isEqualTo(state.getGatewayMsgId());
+ assertThat(message.acked).isTrue();
+ assertThat(state.getForwardDlrUrl()).isNull();
+ assertThat(state.getDeliveryChannel()).isEqualTo(MessageState.DeliveryChannel.NONE);
+ }
+
+ @Test
+ void callbackSubmissionUsesHttpDeliveryChannel() {
+ ArgumentCaptor stateCaptor = ArgumentCaptor.forClass(MessageState.class);
+
+ Response response = submit("https://callback.test/dlr");
+
+ assertThat(response.getStatus()).isEqualTo(Response.Status.ACCEPTED.getStatusCode());
+ verify(dlrService).saveInitialState(stateCaptor.capture());
+ assertThat(stateCaptor.getValue().getDeliveryChannel()).isEqualTo(MessageState.DeliveryChannel.HTTP);
+ }
+
+ @Test
+ void rejectsBeforeQueueAdmissionWhenPersistenceFails() throws InterruptedException {
+ doThrow(new DlrStorageException("database details"))
+ .when(dlrService).saveInitialState(any(MessageState.class));
+
+ Response response = submit("https://callback.test/dlr");
+
+ assertThat(response.getStatus()).isEqualTo(Response.Status.SERVICE_UNAVAILABLE.getStatusCode());
+ assertThat(response.getEntity()).isEqualTo("Temporal failure, try again later.");
+ verify(routerQueue, never()).enqueue(any(StandardMessage.class));
+ }
+
+ @Test
+ void acceptsSubmissionWithoutDlrTrackingWhenPersistenceIsDisabled() throws InterruptedException {
+ when(dlrServices.isUnsatisfied()).thenReturn(true);
+
+ Response response = submit("https://callback.test/dlr");
+
+ assertThat(response.getStatus()).isEqualTo(Response.Status.ACCEPTED.getStatusCode());
+ verify(dlrService, never()).saveInitialState(any(MessageState.class));
+ verify(routerQueue).enqueue(any(StandardMessage.class));
+ }
+
+ @Test
+ void returnsRetryableFailureWhenQueueAdmissionIsInterruptedAfterPersistence() throws InterruptedException {
+ doThrow(new InterruptedException("interrupted"))
+ .when(routerQueue).enqueue(any(StandardMessage.class));
+
+ Response response = submit(null);
+
+ assertThat(response.getStatus()).isEqualTo(Response.Status.SERVICE_UNAVAILABLE.getStatusCode());
+ assertThat(response.getEntity()).isEqualTo("Temporal failure, try again later.");
+ verify(dlrService).saveInitialState(any(MessageState.class));
+ }
+
+ private Response submit(String dlrUrl) {
+ return resource.receiveSms(
+ USERNAME, PASSWORD, "Sender", "306910000000", "Hello", null, null, null,
+ null, null, null, null, dlrUrl, null, null, null, null, null, null, null, null);
+ }
+}
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/message/StandardMessageJsonResourceTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/message/StandardMessageJsonResourceIT.java
similarity index 78%
rename from sendium-core/src/test/java/gr/cytech/sendium/core/message/StandardMessageJsonResourceTest.java
rename to sendium-core/src/test/java/gr/cytech/sendium/core/message/StandardMessageJsonResourceIT.java
index 270f284..166369b 100644
--- a/sendium-core/src/test/java/gr/cytech/sendium/core/message/StandardMessageJsonResourceTest.java
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/message/StandardMessageJsonResourceIT.java
@@ -1,5 +1,7 @@
package gr.cytech.sendium.core.message;
+import gr.cytech.sendium.core.worker.PostgresqlDlrQuarkusTestResource;
+import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
@@ -7,7 +9,8 @@
import static org.hamcrest.Matchers.equalTo;
@QuarkusTest
-class StandardMessageJsonResourceTest {
+@QuarkusTestResource(value = PostgresqlDlrQuarkusTestResource.class, restrictToAnnotatedClass = true)
+class StandardMessageJsonResourceIT {
@Test
void shouldDeserializeStandardMessageWithPrimitiveByteFields() {
given()
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java
index 3a60f63..01f8b71 100644
--- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/client/SmppClientWorkerTest.java
@@ -11,6 +11,7 @@
import gr.cytech.sendium.conf.SendiumConfigurationProvider;
import gr.cytech.sendium.core.message.StandardMessage;
import gr.cytech.sendium.core.queue.Queue;
+import gr.cytech.sendium.core.worker.DlrStorageException;
import gr.cytech.sendium.core.worker.ForwardMoService;
import gr.cytech.sendium.core.worker.Tracker;
import gr.cytech.sendium.external.WorkerResourceProvider;
@@ -37,6 +38,32 @@ void defaultsSensitiveDiagnosticLoggingOff() {
assertThat(config.getBlnPrpt(worker._printMos)).isFalse();
}
+ @Test
+ void dlrProviderNameDefaultsToWorkerFullName() {
+ TestSmppClientWorker worker = new TestSmppClientWorker(
+ new TestConfigurationProvider(), new Queue<>(), new CapturingTracker());
+
+ assertThat(worker.getDlrProviderName()).isEqualTo("test");
+ }
+
+ @Test
+ void dlrProviderNameUsesConfiguredSharedNamespace() {
+ TestSmppClientWorker worker = new TestSmppClientWorker(
+ new TestConfigurationProvider(Map.of("msg.hash.prefix", "provider-cluster")),
+ new Queue<>(), new CapturingTracker());
+
+ assertThat(worker.getDlrProviderName()).isEqualTo("provider-cluster");
+ }
+
+ @Test
+ void blankDlrProviderNameFallsBackToWorkerFullName() {
+ TestSmppClientWorker worker = new TestSmppClientWorker(
+ new TestConfigurationProvider(Map.of("msg.hash.prefix", " ")),
+ new Queue<>(), new CapturingTracker());
+
+ assertThat(worker.getDlrProviderName()).isEqualTo("test");
+ }
+
@Test
void parseDlrAndCreateResponse_whenReceiptIsValid_enqueuesDlrWithRegisteredTlvs() throws Exception {
TestConfigurationProvider config = new TestConfigurationProvider(Map.of(
@@ -56,13 +83,56 @@ void parseDlrAndCreateResponse_whenReceiptIsValid_enqueuesDlrWithRegisteredTlvs(
PduResponse response = worker.parseDlrAndCreateResponse(deliverSm);
assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK);
- assertThat(tracker.dlrSmscId).isEqualTo("abc123");
+ assertThat(tracker.dlrProviderMessageId).isEqualTo("abc123");
assertThat(tracker.dlrFrom).isEqualTo("smsc");
assertThat(tracker.dlrTo).isEqualTo("recipient");
assertThat(tracker.dlrState).isEqualTo(StandardMessage.DLR_STAT_DELIVRD);
assertThat(tracker.dlrTlvs).containsEntry("carrier_1400", "network-a");
}
+ @Test
+ void parseDlrAndCreateResponse_whenReceiptIsIntermediate_acknowledgesWithoutEnqueuing() throws Exception {
+ CapturingTracker tracker = new CapturingTracker();
+ TestSmppClientWorker worker = new TestSmppClientWorker(
+ new TestConfigurationProvider(), new Queue<>(), tracker);
+
+ for (String state : Set.of("ACCEPTD", "ENROUTE")) {
+ DeliverSm deliverSm = new DeliverSm();
+ deliverSm.setSourceAddress(new Address((byte) 1, (byte) 1, "smsc"));
+ deliverSm.setDestAddress(new Address((byte) 1, (byte) 1, "recipient"));
+ deliverSm.setDataCoding(SmppConstants.DATA_CODING_DEFAULT);
+ deliverSm.setShortMessage(CharsetUtil.encode(
+ "id:abc123 sub:001 dlvrd:000 submit date:2401010000 done date: stat:" + state +
+ " err:000 text:pending",
+ CharsetUtil.NAME_GSM));
+
+ PduResponse response = worker.parseDlrAndCreateResponse(deliverSm);
+
+ assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK);
+ }
+ assertThat(tracker.dlrAttempts).isZero();
+ }
+
+ @Test
+ void parseDlrAndCreateResponse_whenStorageFails_returnsSystemErrorForProviderRetry() throws Exception {
+ CapturingTracker tracker = new CapturingTracker();
+ tracker.failDlrCreation = true;
+ TestSmppClientWorker worker = new TestSmppClientWorker(
+ new TestConfigurationProvider(), new Queue<>(), tracker);
+ DeliverSm deliverSm = new DeliverSm();
+ deliverSm.setSourceAddress(new Address((byte) 1, (byte) 1, "smsc"));
+ deliverSm.setDestAddress(new Address((byte) 1, (byte) 1, "recipient"));
+ deliverSm.setDataCoding(SmppConstants.DATA_CODING_DEFAULT);
+ deliverSm.setShortMessage(CharsetUtil.encode(
+ "id:abc123 sub:001 dlvrd:001 submit date:2401010000 done date:2401010001 stat:DELIVRD err:000 text:ok",
+ CharsetUtil.NAME_GSM));
+
+ PduResponse response = worker.parseDlrAndCreateResponse(deliverSm);
+
+ assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_SYSERR);
+ assertThat(tracker.dlrAttempts).isEqualTo(1);
+ }
+
@Test
void parseDlrAndCreateResponse_whenReceiptHasNoMessageId_returnsSystemError() throws Exception {
TestSmppClientWorker worker = new TestSmppClientWorker(new TestConfigurationProvider(), new Queue<>(), new CapturingTracker());
@@ -178,11 +248,56 @@ void handleResponse_whenFailStatus_recordsFailureDlr() {
worker.handleResponse(handler(worker), SmppConstants.STATUS_INVMSGLEN, "smsc-2", msg);
assertThat(tracker.dlrMqId).isEqualTo(17);
- assertThat(tracker.dlrSmscId).isEqualTo("smsc-2");
+ assertThat(tracker.dlrProviderMessageId).isEqualTo("smsc-2");
assertThat(tracker.dlrState).isEqualTo(StandardMessage.DLR_STAT_FAILED);
assertThat(tracker.dlrErrorCode).isEqualTo("7");
}
+ @Test
+ void updateSendStatusAndProviderMessageId_whenStorageFails_keepsSubmitResponseCallbackAlive() {
+ CapturingTracker tracker = new CapturingTracker();
+ tracker.failProviderLink = true;
+ TestSmppClientWorker worker = new TestSmppClientWorker(
+ new TestConfigurationProvider(), new Queue<>(), tracker);
+ StandardMessage msg = messageWithNetwork();
+ msg.serial = "gateway-17";
+
+ String providerMessageId = worker.updateSendStatusAndProviderMessageId("smsc-17", msg);
+
+ assertThat(providerMessageId).isEqualTo("smsc-17");
+ assertThat(tracker.linkAttempts).isEqualTo(1);
+ }
+
+ @Test
+ void updateSendStatusAndProviderMessageId_whenResponseIdIsBlank_usesInternalId() {
+ CapturingTracker tracker = new CapturingTracker();
+ TestSmppClientWorker worker = new TestSmppClientWorker(
+ new TestConfigurationProvider(), new Queue<>(), tracker);
+ StandardMessage msg = messageWithNetwork();
+ msg.serial = "gateway-17";
+
+ String providerMessageId = worker.updateSendStatusAndProviderMessageId(" ", msg);
+
+ assertThat(providerMessageId).isEqualTo("smppclient.test_internal_17");
+ assertThat(tracker.linkAttempts).isEqualTo(1);
+ }
+
+ @Test
+ void failMessage_whenStorageFails_attemptsDlrWithoutEscapingCallback() {
+ CapturingTracker tracker = new CapturingTracker();
+ tracker.failProviderLink = true;
+ tracker.failDlrCreation = true;
+ TestSmppClientWorker worker = new TestSmppClientWorker(
+ new TestConfigurationProvider(), new Queue<>(), tracker);
+ StandardMessage msg = messageWithNetwork();
+ msg.serial = "gateway-17";
+
+ worker.failMessage(SmppConstants.STATUS_INVMSGLEN, "smsc-17", msg);
+
+ assertThat(tracker.linkAttempts).isEqualTo(1);
+ assertThat(tracker.dlrAttempts).isEqualTo(1);
+ }
+
private static SmppClientSessionHandler handler(TestSmppClientWorker worker) {
return new SmppClientSessionHandler(worker, new SmppClientWorker.ConnectionInfo(
null, "localhost", 2775, SmppClientWorker.ConnectionType.NORMAL));
@@ -262,12 +377,16 @@ public void forwardMo(String forwardUrl, MoContext ctx, ForwardFormat format) {
private static class CapturingTracker implements Tracker {
private int dlrMqId;
- private String dlrSmscId;
+ private String dlrProviderMessageId;
private String dlrFrom;
private String dlrTo;
private int dlrState;
private String dlrErrorCode;
private HashMap dlrTlvs;
+ private boolean failProviderLink;
+ private boolean failDlrCreation;
+ private int linkAttempts;
+ private int dlrAttempts;
@Override
public void init() {
@@ -283,7 +402,12 @@ public void configure(String key, String newValue, String oldValue) {
}
@Override
- public int updateSendStatusAndExtID(String smsid, StandardMessage pMsg, String smscid) {
+ public int updateSendStatusAndExtID(String hashedProviderMessageId, StandardMessage message,
+ String providerMessageId) {
+ linkAttempts++;
+ if (failProviderLink) {
+ throw new DlrStorageException("Failed to link provider DLR ID");
+ }
return 1;
}
@@ -298,10 +422,15 @@ public String getVendorPriceGateway() {
}
@Override
- public void createAndEnqueueDLR(int mqid, String smscid, String smsid, String from, String to, String body,
- int state, String errorCode, HashMap tlvs) {
+ public void createAndEnqueueDLR(int mqid, String providerMessageId, String hashedProviderMessageId,
+ String from, String to, String body, int state, String errorCode,
+ HashMap tlvs) {
+ dlrAttempts++;
+ if (failDlrCreation) {
+ throw new DlrStorageException("Failed to resolve DLR state");
+ }
this.dlrMqId = mqid;
- this.dlrSmscId = smscid;
+ this.dlrProviderMessageId = providerMessageId;
this.dlrFrom = from;
this.dlrTo = to;
this.dlrState = state;
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/DlrDeliveryBatchTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/DlrDeliveryBatchTest.java
new file mode 100644
index 0000000..4f5a39f
--- /dev/null
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/DlrDeliveryBatchTest.java
@@ -0,0 +1,113 @@
+package gr.cytech.sendium.core.smpp.server;
+
+import gr.cytech.sendium.core.message.StandardMessage;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DlrDeliveryBatchTest {
+ @Mock private SmppServerMessageStore store;
+
+ private StandardMessage message;
+
+ @BeforeEach
+ void setUp() {
+ message = new StandardMessage();
+ message.serial = "gateway-1";
+ message.type = StandardMessage.MSG_DLR;
+ }
+
+ @Test
+ void singlePartSuccessCompletesAttempt() {
+ when(store.completeDlrDeliveryAttempt(message, 3)).thenReturn(true);
+ DlrDeliveryBatch batch = batch(3, Set.of(0));
+
+ batch.partSucceeded(0);
+
+ verify(store).completeDlrDeliveryAttempt(message, 3);
+ verify(store, never()).releaseDlrDeliveryAttempt(
+ message, 3, DlrDeliveryBatch.COMPLETION_STORAGE_ERROR);
+ assertThat(batch.isActive()).isFalse();
+ }
+
+ @Test
+ void multipartCompletesAfterAllDistinctPartsAndIgnoresDuplicateResponse() {
+ when(store.completeDlrDeliveryAttempt(message, 4)).thenReturn(true);
+ DlrDeliveryBatch batch = batch(4, Set.of(0, 1, 2));
+
+ batch.partSucceeded(0);
+ batch.partSucceeded(0);
+ batch.partSucceeded(2);
+ verify(store, never()).completeDlrDeliveryAttempt(message, 4);
+ batch.partSucceeded(1);
+
+ verify(store, times(1)).completeDlrDeliveryAttempt(message, 4);
+ }
+
+ @Test
+ void firstFailureReleasesOnlyOnce() {
+ when(store.releaseDlrDeliveryAttempt(message, 5, "timeout")).thenReturn(true);
+ DlrDeliveryBatch batch = batch(5, Set.of(0, 1));
+
+ batch.fail("timeout");
+ batch.fail("non_ok_response");
+ batch.partSucceeded(0);
+
+ verify(store, times(1)).releaseDlrDeliveryAttempt(message, 5, "timeout");
+ verify(store, never()).completeDlrDeliveryAttempt(message, 5);
+ }
+
+ @Test
+ void completionStorageFailureReleasesAttempt() {
+ when(store.completeDlrDeliveryAttempt(message, 6)).thenReturn(false);
+ when(store.releaseDlrDeliveryAttempt(message, 6, DlrDeliveryBatch.COMPLETION_STORAGE_ERROR))
+ .thenReturn(true);
+ DlrDeliveryBatch batch = batch(6, Set.of(0));
+
+ batch.partSucceeded(0);
+
+ verify(store).releaseDlrDeliveryAttempt(message, 6, DlrDeliveryBatch.COMPLETION_STORAGE_ERROR);
+ }
+
+ @Test
+ void completionStorageExceptionReleasesAttempt() {
+ when(store.completeDlrDeliveryAttempt(message, 9)).thenThrow(new IllegalStateException("database down"));
+ when(store.releaseDlrDeliveryAttempt(message, 9, DlrDeliveryBatch.COMPLETION_STORAGE_ERROR))
+ .thenReturn(true);
+ DlrDeliveryBatch batch = batch(9, Set.of(0));
+
+ batch.partSucceeded(0);
+
+ verify(store).releaseDlrDeliveryAttempt(message, 9, DlrDeliveryBatch.COMPLETION_STORAGE_ERROR);
+ }
+
+ @Test
+ void callbackFromTerminalOldAttemptCannotAffectNewAttempt() {
+ when(store.releaseDlrDeliveryAttempt(message, 7, "timeout")).thenReturn(true);
+ when(store.completeDlrDeliveryAttempt(message, 8)).thenReturn(true);
+ DlrDeliveryBatch oldBatch = batch(7, Set.of(0));
+ DlrDeliveryBatch newBatch = batch(8, Set.of(0));
+
+ oldBatch.fail("timeout");
+ newBatch.partSucceeded(0);
+ oldBatch.partSucceeded(0);
+
+ verify(store, never()).completeDlrDeliveryAttempt(message, 7);
+ verify(store).completeDlrDeliveryAttempt(message, 8);
+ }
+
+ private DlrDeliveryBatch batch(int attempt, Set expectedParts) {
+ return new DlrDeliveryBatch<>(message, attempt, expectedParts, store, null);
+ }
+}
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/InMemorySmppServerMessageStoreTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/InMemorySmppServerMessageStoreTest.java
deleted file mode 100644
index e63447c..0000000
--- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/InMemorySmppServerMessageStoreTest.java
+++ /dev/null
@@ -1,177 +0,0 @@
-package gr.cytech.sendium.core.smpp.server;
-
-import gr.cytech.sendium.core.message.StandardMessage;
-import gr.cytech.sendium.core.worker.InMemoryDlrService;
-import gr.cytech.sendium.core.worker.MessageState;
-import gr.cytech.sendium.external.WorkerResourceProvider;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-import org.mockito.junit.jupiter.MockitoSettings;
-import org.mockito.quality.Strictness;
-
-import java.sql.Timestamp;
-import java.util.ArrayList;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.*;
-
-@ExtendWith(MockitoExtension.class)
-@MockitoSettings(strictness = Strictness.LENIENT)
-class InMemorySmppServerMessageStoreTest {
-
- @Mock
- private SmppServerWorker worker;
-
- @Mock
- private WorkerResourceProvider workerResources;
-
- @Mock
- private InMemoryDlrService dlrService;
-
- private InMemorySmppServerMessageStore messageStore;
-
- @BeforeEach
- void setUp() {
- when(worker.getWorkerResources()).thenReturn(workerResources);
- when(workerResources.getDlrService()).thenReturn(dlrService);
- when(worker.getMaxRetries()).thenReturn(5);
-
- messageStore = new InMemorySmppServerMessageStore(worker);
- }
-
- @Test
- void persistMessages_SavesStateForEachMessage() {
- List> events = new ArrayList<>();
-
- StandardMessage msg1 = new StandardMessage();
- msg1.serial = "gw-1";
- msg1.owner_id = "account1";
- msg1.systemId = "sys1";
- msg1.from = "from1";
- msg1.to = "to1";
-
- StandardMessage msg2 = new StandardMessage();
- msg2.serial = "gw-2";
- msg2.owner_id = "account2";
- msg2.systemId = "sys2";
- msg2.from = "from2";
- msg2.to = "to2";
-
- InEvent event1 = new InEvent<>(msg1, null, 1, new Timestamp(System.currentTimeMillis()));
- InEvent event2 = new InEvent<>(msg2, null, 2, new Timestamp(System.currentTimeMillis()));
-
- events.add(event1);
- events.add(event2);
-
- messageStore.persistMessages(events);
-
- ArgumentCaptor captor = ArgumentCaptor.forClass(MessageState.class);
- verify(dlrService, times(2)).saveInitialState(captor.capture());
- assertEquals("account1", captor.getAllValues().get(0).getAccountId());
- assertEquals("sys1", captor.getAllValues().get(0).getSystemId());
- assertEquals("account2", captor.getAllValues().get(1).getAccountId());
- assertEquals("sys2", captor.getAllValues().get(1).getSystemId());
- }
-
- @Test
- void persistMessages_SavesReassembledPartIds() {
- StandardMessage msg = new StandardMessage();
- msg.serial = "gw-1";
- msg.owner_id = "account1";
- msg.systemId = "sys1";
- msg.from = "from1";
- msg.to = "to1";
- msg.reassembledParts = new ArrayList<>(List.of("part-1", "part-2"));
-
- messageStore.persistMessages(List.of(new InEvent<>(msg, null, 1, new Timestamp(System.currentTimeMillis()))));
-
- ArgumentCaptor captor = ArgumentCaptor.forClass(MessageState.class);
- verify(dlrService).saveInitialState(captor.capture());
- assertEquals(List.of("part-1", "part-2"), captor.getValue().getReassembledParts());
- }
-
- @Test
- void persistMessages_WithNullMessage_Skips() {
- List> events = new ArrayList<>();
-
- InEvent event = new InEvent<>(null, null, 1, new Timestamp(System.currentTimeMillis()));
-
- events.add(event);
-
- messageStore.persistMessages(events);
-
- verify(dlrService, never()).saveInitialState(any(MessageState.class));
- }
-
- @Test
- void getMaxAttempts_DelegatesToWorker() {
- int result = messageStore.getMaxAttempts(true);
-
- assertEquals(5, result);
- }
-
- @Test
- void getMaxAttempts_DefaultsTo3_WhenNoWorker() {
- InMemorySmppServerMessageStore storeWithNullWorker = new InMemorySmppServerMessageStore(null);
-
- int result = storeWithNullWorker.getMaxAttempts(true);
-
- assertEquals(3, result);
- }
-
- @Test
- void markAsUnpushed_Dlr_SavesToDlrService() {
- StandardMessage msg = new StandardMessage();
- msg.type = StandardMessage.MSG_DLR;
- when(dlrService.saveUnpushedDlr(msg)).thenReturn(true);
-
- boolean result = messageStore.markAsUnpushed(msg);
-
- assertTrue(result);
- verify(dlrService).saveUnpushedDlr(msg);
- }
-
- @Test
- void markAsUnpushed_NonDlr_ReturnsFalse() {
- StandardMessage msg = new StandardMessage();
- msg.type = StandardMessage.MSG_TEXT;
-
- boolean result = messageStore.markAsUnpushed(msg);
-
- assertFalse(result);
- verify(dlrService, never()).saveUnpushedDlr(any());
- }
-
- @Test
- void onClientConnected_ReEnqueuesAndRemovesMatchingDlrs() {
- StandardMessage dlr = new StandardMessage();
- dlr.type = StandardMessage.MSG_DLR;
- dlr.owner_id = "account1";
- dlr.systemId = "sys1";
- when(dlrService.claimUnpushedDlrs("sys1")).thenReturn(List.of(dlr));
- when(worker.enqueueNoExceptions(dlr)).thenReturn(true);
-
- messageStore.onClientConnected("sys1");
-
- verify(worker).enqueueNoExceptions(dlr);
- verify(dlrService).removeUnpushedDlr(dlr);
- }
-
- @Test
- void onClientConnected_LeavesDlrStoredWhenReEnqueueFails() {
- StandardMessage dlr = new StandardMessage();
- dlr.type = StandardMessage.MSG_DLR;
- when(dlrService.claimUnpushedDlrs("sys1")).thenReturn(List.of(dlr));
- when(worker.enqueueNoExceptions(dlr)).thenReturn(false);
-
- messageStore.onClientConnected("sys1");
-
- verify(dlrService, never()).removeUnpushedDlr(any());
- verify(dlrService).releaseUnpushedDlrClaim(dlr);
- }
-}
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerSessionHandlerTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerSessionHandlerTest.java
index bd2d62f..bd0cb67 100644
--- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerSessionHandlerTest.java
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerSessionHandlerTest.java
@@ -1,8 +1,12 @@
package gr.cytech.sendium.core.smpp.server;
+import com.cloudhopper.smpp.PduAsyncResponse;
import com.cloudhopper.smpp.SmppConstants;
import com.cloudhopper.smpp.SmppSession;
import com.cloudhopper.smpp.SmppSessionConfiguration;
+import com.cloudhopper.smpp.pdu.DeliverSm;
+import com.cloudhopper.smpp.pdu.DeliverSmResp;
+import com.cloudhopper.smpp.pdu.GenericNack;
import com.cloudhopper.smpp.pdu.SubmitSm;
import com.cloudhopper.smpp.pdu.SubmitSmResp;
import com.cloudhopper.smpp.tlv.Tlv;
@@ -18,13 +22,12 @@
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
+import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SmppServerSessionHandlerTest {
@@ -34,6 +37,7 @@ class SmppServerSessionHandlerTest {
@Mock private SmppSessionContext sessionContext;
@Mock private SubmitSmProcessor submitProcessor;
@Mock private SmppSessionConfiguration sessionConfiguration;
+ @Mock private SmppServerMessageStore messageStore;
private SmppServerSessionHandler handler;
@@ -153,4 +157,92 @@ void validateScheduleDeliveryTime_whenInvalid_shouldEnqueueInvalidScheduleRespon
assertThat(result).isNull();
assertThat(respCaptor.getValue().getCommandStatus()).isEqualTo(SmppConstants.STATUS_INVSCHED);
}
+
+ @Test
+ void expectedOkDeliverSmResponseCompletesPart() {
+ DlrDeliveryBatch batch = batch(1);
+ when(messageStore.completeDlrDeliveryAttempt(any(), eq(1))).thenReturn(true);
+ DeliverSm request = request(batch);
+ DeliverSmResp response = new DeliverSmResp();
+ response.setCommandStatus(SmppConstants.STATUS_OK);
+
+ handler.fireExpectedPduResponseReceived(asyncResponse(request, response));
+
+ verify(messageStore).completeDlrDeliveryAttempt(any(), eq(1));
+ }
+
+ @Test
+ void expectedNonOkDeliverSmResponseReleasesAttempt() {
+ DlrDeliveryBatch batch = batch(2);
+ when(messageStore.releaseDlrDeliveryAttempt(any(), eq(2), eq("non_ok_response"))).thenReturn(true);
+ DeliverSm request = request(batch);
+ DeliverSmResp response = new DeliverSmResp();
+ response.setCommandStatus(SmppConstants.STATUS_SYSERR);
+
+ handler.fireExpectedPduResponseReceived(asyncResponse(request, response));
+
+ verify(messageStore).releaseDlrDeliveryAttempt(any(), eq(2), eq("non_ok_response"));
+ }
+
+ @Test
+ void expectedWrongOrGenericResponseReleasesAttempt() {
+ DlrDeliveryBatch wrongBatch = batch(3);
+ DlrDeliveryBatch nackBatch = batch(4);
+ when(messageStore.releaseDlrDeliveryAttempt(any(), eq(3), eq("wrong_response"))).thenReturn(true);
+ when(messageStore.releaseDlrDeliveryAttempt(any(), eq(4), eq("generic_nack"))).thenReturn(true);
+
+ handler.fireExpectedPduResponseReceived(asyncResponse(request(wrongBatch), new SubmitSmResp()));
+ handler.fireExpectedPduResponseReceived(asyncResponse(request(nackBatch), new GenericNack()));
+
+ verify(messageStore).releaseDlrDeliveryAttempt(any(), eq(3), eq("wrong_response"));
+ verify(messageStore).releaseDlrDeliveryAttempt(any(), eq(4), eq("generic_nack"));
+ }
+
+ @Test
+ void expiredDeliverSmReleasesAttemptWithoutLegacyUpsert() {
+ DlrDeliveryBatch batch = batch(5);
+ when(messageStore.releaseDlrDeliveryAttempt(any(), eq(5), eq("timeout"))).thenReturn(true);
+ DeliverSm request = request(batch);
+
+ handler.firePduRequestExpired(request);
+
+ verify(messageStore).releaseDlrDeliveryAttempt(any(), eq(5), eq("timeout"));
+ verify(worker, never()).markAsUnpushed(any());
+ }
+
+ @Test
+ void unexpectedDisconnectReleasesOutstandingBatches() {
+ DlrDeliveryBatch batch = batch(6);
+ SmppServerBindHandler bindHandler = mock(SmppServerBindHandler.class);
+ ServerConnections connections = mock(ServerConnections.class);
+ when(worker.getBindHandler()).thenReturn(bindHandler);
+ when(bindHandler.getConnections()).thenReturn(connections);
+ when(messageStore.releaseDlrDeliveryAttempt(any(), eq(6), eq("session_closed"))).thenReturn(true);
+ assertThat(handler.registerDlrBatch(batch)).isTrue();
+
+ handler.fireChannelUnexpectedlyClosed();
+
+ verify(messageStore).releaseDlrDeliveryAttempt(any(), eq(6), eq("session_closed"));
+ verify(connections).removeConnection(handler);
+ }
+
+ private DlrDeliveryBatch batch(int attempt) {
+ StandardMessage message = new StandardMessage();
+ message.serial = "gateway-" + attempt;
+ message.type = StandardMessage.MSG_DLR;
+ return new DlrDeliveryBatch<>(message, attempt, Set.of(0), messageStore, handler);
+ }
+
+ private DeliverSm request(DlrDeliveryBatch batch) {
+ DeliverSm request = new DeliverSm();
+ request.setReferenceObject(new DlrDeliverSmReference<>(handler, batch, 0, "receipt-1"));
+ return request;
+ }
+
+ private PduAsyncResponse asyncResponse(DeliverSm request, com.cloudhopper.smpp.pdu.PduResponse response) {
+ PduAsyncResponse asyncResponse = mock(PduAsyncResponse.class);
+ when(asyncResponse.getRequest()).thenReturn(request);
+ when(asyncResponse.getResponse()).thenReturn(response);
+ return asyncResponse;
+ }
}
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerWorkerReassemblyTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerWorkerReassemblyTest.java
index a2227f6..9a9dda9 100644
--- a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerWorkerReassemblyTest.java
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/SmppServerWorkerReassemblyTest.java
@@ -2,23 +2,30 @@
import com.cloudhopper.commons.charset.CharsetUtil;
import com.cloudhopper.smpp.SmppConstants;
+import com.cloudhopper.smpp.SmppSession;
import com.cloudhopper.smpp.pdu.DeliverSm;
+import com.cloudhopper.smpp.pdu.Pdu;
+import com.cloudhopper.smpp.pdu.SubmitSm;
+import com.cloudhopper.smpp.pdu.SubmitSmResp;
import gr.cytech.sendium.conf.PropertyChangeListener;
import gr.cytech.sendium.conf.SendiumConfigurationProvider;
import gr.cytech.sendium.core.message.StandardMessage;
import gr.cytech.sendium.core.queue.Queue;
import org.junit.jupiter.api.Test;
+import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.OptionalInt;
import java.util.Set;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.*;
class SmppServerWorkerReassemblyTest {
@@ -26,6 +33,9 @@ class SmppServerWorkerReassemblyTest {
void completeUdhPartsAreReassembledAndRoutedToRouterQueue() throws Exception {
Queue routerQueue = new Queue<>();
TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), routerQueue);
+ SmppServerMessageStore store = mock(SmppServerMessageStore.class);
+ when(store.persistsBeforeAcknowledgement()).thenReturn(true);
+ worker.setMessageStore(store);
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1);
MessagePartsHandler handler = new MessagePartsHandler<>(
worker.new CcatMessagePartsEventsListener(), TimeUnit.SECONDS.toMillis(30), executor);
@@ -34,16 +44,16 @@ void completeUdhPartsAreReassembledAndRoutedToRouterQueue() throws Exception {
handler.addMessagePart(messagePart("0500037F0202", "World", "part-2"));
handler.addMessagePart(messagePart("0500037F0201", "Hello ", "part-1"));
+ assertThat(routerQueue.dequeue(10)).isNull();
+ InEvent persisted = worker.getInEventQueue().poll(1_000, TimeUnit.MILLISECONDS);
+ assertThat(persisted).isNotNull();
+ worker.handlePersistedMessages(List.of(persisted));
StandardMessage routed = routerQueue.dequeue(1_000);
-
- assertThat(routed).isNotNull();
assertThat(routed.body).isEqualTo("Hello World");
assertThat(routed.binheader).isNull();
assertThat(routed.reassembledParts).containsExactly("part-1", "part-2");
assertThat(worker.workerQueueMessages).isEmpty();
- InEvent persisted = worker.getInEventQueue().poll(1_000, TimeUnit.MILLISECONDS);
- assertThat(persisted).isNotNull();
assertThat(persisted.pMsg).isSameAs(routed);
} finally {
executor.shutdownNow();
@@ -54,6 +64,9 @@ void completeUdhPartsAreReassembledAndRoutedToRouterQueue() throws Exception {
void delayedUdhPartsAreRoutedToRouterQueueWithoutBecomingDeliverSm() throws Exception {
Queue routerQueue = new Queue<>();
TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), routerQueue);
+ SmppServerMessageStore store = mock(SmppServerMessageStore.class);
+ when(store.persistsBeforeAcknowledgement()).thenReturn(true);
+ worker.setMessageStore(store);
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1);
MessagePartsHandler handler = new MessagePartsHandler<>(
worker.new CcatMessagePartsEventsListener(), 10, executor);
@@ -63,16 +76,13 @@ void delayedUdhPartsAreRoutedToRouterQueueWithoutBecomingDeliverSm() throws Exce
handler.addMessagePart(part);
StandardMessage routed = routerQueue.dequeue(1_000);
-
assertThat(routed).isSameAs(part);
assertThat(routed.body).isEqualTo("Hello ");
assertThat(routed.binheader).isEqualTo("0500037F0201");
assertThat(routed.reassembledParts).isNull();
assertThat(worker.workerQueueMessages).isEmpty();
- InEvent persisted = worker.getInEventQueue().poll(1_000, TimeUnit.MILLISECONDS);
- assertThat(persisted).isNotNull();
- assertThat(persisted.pMsg).isSameAs(part);
+ assertThat(worker.getInEventQueue()).isEmpty();
} finally {
executor.shutdownNow();
}
@@ -106,6 +116,246 @@ void reassembledDlrGeneratesDeliverSmPerOriginalPartIdWithSameStatus() throws Ex
assertThat(bodies).anySatisfy(body -> assertThat(body).contains("id:part-3"));
}
+ @Test
+ void multipartDlrStartsOneAttemptAndUsesTypedPartReferences() throws Exception {
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), new Queue<>());
+ SmppServerMessageStore store = mock(SmppServerMessageStore.class);
+ SmppServerSessionHandler handler = reachableHandler(worker);
+ worker.setMessageStore(store);
+ when(store.tracksDlrDeliveryAttempts()).thenReturn(true);
+ when(store.startDlrDeliveryAttempt(any())).thenReturn(OptionalInt.of(9));
+ StandardMessage dlr = dlrMessage();
+ dlr.reassembledParts = new ArrayList<>(List.of("part-1", "part-2", "part-3"));
+
+ assertThat(worker.doMessage(0, dlr)).isNull();
+
+ verify(store, times(1)).startDlrDeliveryAttempt(dlr);
+ assertThat(worker.outgoingPdus).hasSize(3).allSatisfy(pdu -> {
+ assertThat(pdu.getReferenceObject()).isInstanceOf(DlrDeliverSmReference.class);
+ DlrDeliverSmReference> reference = (DlrDeliverSmReference>) pdu.getReferenceObject();
+ assertThat(reference.handler()).isSameAs(handler);
+ assertThat(reference.batch().getAttempt()).isEqualTo(9);
+ });
+ assertThat(worker.outgoingPdus.stream()
+ .map(pdu -> ((DlrDeliverSmReference>) pdu.getReferenceObject()).receiptMessageId()))
+ .containsExactly("part-1", "part-2", "part-3");
+ }
+
+ @Test
+ void dlrWithoutReachableSessionLeavesPendingWithoutStartingAttempt() throws Exception {
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), new Queue<>());
+ SmppServerMessageStore store = mock(SmppServerMessageStore.class);
+ SmppServerBindHandler bindHandler = mock(SmppServerBindHandler.class);
+ worker.setMessageStore(store);
+ worker.setBindHandler(bindHandler);
+ when(store.tracksDlrDeliveryAttempts()).thenReturn(true);
+
+ assertThat(worker.doMessage(0, dlrMessage())).isNull();
+
+ verify(store, never()).startDlrDeliveryAttempt(any());
+ verify(store, never()).markAsUnpushed(any());
+ assertThat(worker.outgoingPdus).isEmpty();
+ }
+
+ @Test
+ void duplicateDlrAttemptDoesNotSend() throws Exception {
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), new Queue<>());
+ SmppServerMessageStore store = mock(SmppServerMessageStore.class);
+ reachableHandler(worker);
+ worker.setMessageStore(store);
+ when(store.tracksDlrDeliveryAttempts()).thenReturn(true);
+ when(store.startDlrDeliveryAttempt(any())).thenReturn(OptionalInt.empty());
+
+ assertThat(worker.doMessage(0, dlrMessage())).isNull();
+
+ assertThat(worker.outgoingPdus).isEmpty();
+ verify(store, never()).releaseDlrDeliveryAttempt(any(), anyInt(), anyString());
+ }
+
+ @Test
+ void enqueueFailureReleasesStartedAttempt() throws Exception {
+ TestSmppServerWorker worker = new TestSmppServerWorker(
+ new TestConfigurationProvider(), new Queue<>(), true);
+ SmppServerMessageStore store = mock(SmppServerMessageStore.class);
+ reachableHandler(worker);
+ worker.setMessageStore(store);
+ when(store.tracksDlrDeliveryAttempts()).thenReturn(true);
+ when(store.startDlrDeliveryAttempt(any())).thenReturn(OptionalInt.of(10));
+ when(store.releaseDlrDeliveryAttempt(any(), eq(10), eq("enqueue_failed"))).thenReturn(true);
+ StandardMessage dlr = dlrMessage();
+ dlr.reassembledParts = new ArrayList<>(List.of("part-1", "part-2"));
+
+ assertThat(worker.doMessage(0, dlr)).isNull();
+
+ verify(store).releaseDlrDeliveryAttempt(dlr, 10, "enqueue_failed");
+ assertThat(worker.outgoingPdus).hasSize(1);
+ assertThat(((DlrDeliverSmReference>) worker.outgoingPdus.getFirst().getReferenceObject())
+ .batch().isActive()).isFalse();
+ }
+
+ @Test
+ void generationFailureDoesNotStartOrMutateDurableAttempt() throws Exception {
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), new Queue<>());
+ SmppServerMessageStore store = mock(SmppServerMessageStore.class);
+ reachableHandler(worker);
+ worker.setMessageStore(store);
+ when(store.tracksDlrDeliveryAttempts()).thenReturn(true);
+ StandardMessage dlr = dlrMessage();
+ dlr.errcode = "not-a-number";
+
+ assertThat(worker.doMessage(0, dlr)).isNull();
+
+ verify(store, never()).startDlrDeliveryAttempt(any());
+ verify(store, never()).releaseDlrDeliveryAttempt(any(), anyInt(), anyString());
+ assertThat(worker.outgoingPdus).isEmpty();
+ }
+
+ @Test
+ void dlrWithoutDurableTrackingKeepsExistingInMemoryRetryBehavior() throws Exception {
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), new Queue<>());
+ SmppServerMessageStore store = mock(SmppServerMessageStore.class);
+ SmppServerBindHandler bindHandler = mock(SmppServerBindHandler.class);
+ worker.setMessageStore(store);
+ worker.setBindHandler(bindHandler);
+ StandardMessage dlr = dlrMessage();
+
+ assertThat(worker.doMessage(0, dlr)).isSameAs(dlr);
+
+ verify(store).markAsUnpushed(dlr);
+ verify(store, never()).startDlrDeliveryAttempt(any());
+ }
+
+ @Test
+ void normalSubmissionRoutesAndAcknowledgesOnlyAfterPersistence() throws Exception {
+ Queue routerQueue = new Queue<>();
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), routerQueue);
+ StandardMessage message = messagePart(null, "hello", null);
+ SubmitSm submitSm = new SubmitSm();
+ submitSm.setSequenceNumber(42);
+ InEvent event = new InEvent<>(message, submitSm, 1,
+ new Timestamp(System.currentTimeMillis()));
+
+ worker.enqueueIn(event);
+
+ InEvent queued = worker.getInEventQueue().poll();
+ assertThat(queued).isSameAs(event);
+ assertThat(routerQueue.dequeue(10)).isNull();
+ assertThat(worker.outgoingPdus).isEmpty();
+
+ worker.handlePersistedMessages(List.of(queued));
+
+ assertThat(routerQueue.dequeue(1_000)).isSameAs(message);
+ assertThat(worker.outgoingPdus).singleElement().satisfies(pdu -> {
+ assertThat(pdu).isInstanceOf(SubmitSmResp.class);
+ SubmitSmResp response = (SubmitSmResp) pdu;
+ assertThat(response.getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK);
+ assertThat(response.getMessageId()).isEqualTo(message.serial);
+ });
+ }
+
+ @Test
+ void persistenceFailureReturnsSystemErrorWithoutRouting() throws Exception {
+ Queue routerQueue = new Queue<>();
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), routerQueue);
+ StandardMessage message = messagePart(null, "hello", null);
+ SubmitSm submitSm = new SubmitSm();
+ InEvent event = new InEvent<>(message, submitSm, 1,
+ new Timestamp(System.currentTimeMillis()));
+ worker.enqueueIn(event);
+ InEvent queued = worker.getInEventQueue().poll();
+
+ worker.handleMessagePersistenceFailure(List.of(queued));
+
+ assertThat(routerQueue.dequeue(10)).isNull();
+ assertThat(worker.outgoingPdus).singleElement().satisfies(pdu -> {
+ assertThat(pdu).isInstanceOf(SubmitSmResp.class);
+ assertThat(pdu.getCommandStatus()).isEqualTo(SmppConstants.STATUS_SYSERR);
+ });
+ }
+
+ @Test
+ void submissionDuringShutdownIsRejectedWithoutQueueAdmission() throws Exception {
+ Queue routerQueue = new Queue<>();
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), routerQueue);
+ worker.setKeepOnRunning(false);
+ InEvent event = new InEvent<>(messagePart(null, "hello", null), new SubmitSm(), 1,
+ new Timestamp(System.currentTimeMillis()));
+
+ worker.enqueueIn(event);
+
+ assertThat(worker.getInEventQueue()).isEmpty();
+ assertThat(routerQueue.dequeue(10)).isNull();
+ assertThat(worker.outgoingPdus).singleElement()
+ .satisfies(pdu -> assertThat(pdu.getCommandStatus()).isEqualTo(SmppConstants.STATUS_SYSERR));
+ }
+
+ @Test
+ void routerAdmissionFailureReturnsSystemErrorAfterPersistence() throws Exception {
+ Queue routerQueue = new Queue<>() {
+ @Override
+ public void enqueue(StandardMessage message) throws InterruptedException {
+ throw new InterruptedException("router unavailable");
+ }
+ };
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), routerQueue);
+ StandardMessage message = messagePart(null, "hello", null);
+ SubmitSm submitSm = new SubmitSm();
+ InEvent event = new InEvent<>(message, submitSm, 1,
+ new Timestamp(System.currentTimeMillis()));
+ worker.enqueueIn(event);
+ InEvent queued = worker.getInEventQueue().poll();
+
+ try {
+ worker.handlePersistedMessages(List.of(queued));
+
+ assertThat(worker.outgoingPdus).singleElement()
+ .satisfies(pdu -> assertThat(pdu.getCommandStatus()).isEqualTo(SmppConstants.STATUS_SYSERR));
+ } finally {
+ Thread.interrupted();
+ }
+ }
+
+ @Test
+ void failedAggregatePersistenceRequeuesWithoutAnotherClientResponse() {
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), new Queue<>());
+ StandardMessage aggregate = messagePart(null, "Hello World", "part-1");
+ aggregate.reassembledParts = new ArrayList<>(List.of("part-1", "part-2"));
+ InEvent event = new InEvent<>(aggregate, null, 1,
+ new Timestamp(System.currentTimeMillis()));
+
+ worker.handleMessagePersistenceFailure(List.of(event));
+
+ assertThat(worker.getInEventQueue()).containsExactly(event);
+ assertThat(worker.outgoingPdus).isEmpty();
+ }
+
+ @Test
+ void multipartPartIsBufferedAndAcknowledgedOnlyAfterProvisionalPersistence() throws Exception {
+ Queue routerQueue = new Queue<>();
+ TestSmppServerWorker worker = new TestSmppServerWorker(new TestConfigurationProvider(), routerQueue);
+ ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1);
+ worker.setMessagePartsHandler(new MessagePartsHandler<>(
+ worker.new CcatMessagePartsEventsListener(), TimeUnit.SECONDS.toMillis(30), executor));
+ StandardMessage part = messagePart("0500037F0201", "Hello ", null);
+ SubmitSm submitSm = new SubmitSm();
+ InEvent event = new InEvent<>(part, submitSm, 1,
+ new Timestamp(System.currentTimeMillis()));
+
+ try {
+ worker.enqueueIn(event);
+ InEvent queued = worker.getInEventQueue().poll();
+ assertThat(worker.outgoingPdus).isEmpty();
+
+ worker.handlePersistedMessages(List.of(queued));
+
+ assertThat(routerQueue.dequeue(10)).isNull();
+ assertThat(worker.outgoingPdus).singleElement()
+ .satisfies(pdu -> assertThat(pdu.getCommandStatus()).isEqualTo(SmppConstants.STATUS_OK));
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
private static StandardMessage messagePart(String udh, String body, String serial) {
StandardMessage message = new StandardMessage();
message.owner_id = "account-a";
@@ -120,17 +370,65 @@ private static StandardMessage messagePart(String udh, String body, String seria
return message;
}
+ private static StandardMessage dlrMessage() {
+ StandardMessage dlr = new StandardMessage();
+ dlr.serial = "gateway-1";
+ dlr.owner_id = "account-a";
+ dlr.systemId = "system-a";
+ dlr.from = "306900000001";
+ dlr.to = "sender";
+ dlr.type = StandardMessage.MSG_DLR;
+ dlr.state = StandardMessage.DLR_STAT_DELIVRD;
+ dlr.errcode = "0";
+ return dlr;
+ }
+
+ private SmppServerSessionHandler reachableHandler(TestSmppServerWorker worker) {
+ SmppServerBindHandler bindHandler = mock(SmppServerBindHandler.class);
+ SmppServerSessionHandler handler = mock(SmppServerSessionHandler.class);
+ SmppSession session = mock(SmppSession.class);
+ when(bindHandler.isConnectionReachable("account-a")).thenReturn(true);
+ when(bindHandler.isSystemIdReachable("account-a", "system-a")).thenReturn(true);
+ when(bindHandler.getHandlerForSending("account-a", "system-a")).thenReturn(handler);
+ when(handler.getSession()).thenReturn(session);
+ when(session.isBound()).thenReturn(true);
+ when(handler.registerDlrBatch(any())).thenReturn(true);
+ worker.setBindHandler(bindHandler);
+ return handler;
+ }
+
private static class TestSmppServerWorker extends SmppServerWorker {
private final List workerQueueMessages = new ArrayList<>();
+ private final List outgoingPdus = new ArrayList<>();
+ private final boolean failSecondDlrEnqueue;
TestSmppServerWorker(SendiumConfigurationProvider configurationProvider, Queue routerQueue) {
+ this(configurationProvider, routerQueue, false);
+ }
+
+ TestSmppServerWorker(SendiumConfigurationProvider configurationProvider, Queue routerQueue,
+ boolean failSecondDlrEnqueue) {
super(configurationProvider, "smpp", routerQueue);
+ this.failSecondDlrEnqueue = failSecondDlrEnqueue;
+ }
+
+ void setBindHandler(SmppServerBindHandler bindHandler) {
+ this.bindHandler = bindHandler;
}
@Override
public void enqueue(StandardMessage pMsg) {
workerQueueMessages.add(pMsg);
}
+
+ @Override
+ public void enqueueOut(Pdu event) {
+ if (failSecondDlrEnqueue && outgoingPdus.size() == 1) {
+ throw new IllegalStateException("queue rejected");
+ }
+ outgoingPdus.add(event);
+ }
+
}
private static class TestConfigurationProvider implements SendiumConfigurationProvider {
diff --git a/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/StandardSmppServerMessageStoreTest.java b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/StandardSmppServerMessageStoreTest.java
new file mode 100644
index 0000000..9d83449
--- /dev/null
+++ b/sendium-core/src/test/java/gr/cytech/sendium/core/smpp/server/StandardSmppServerMessageStoreTest.java
@@ -0,0 +1,287 @@
+package gr.cytech.sendium.core.smpp.server;
+
+import com.cloudhopper.smpp.pdu.SubmitSm;
+import gr.cytech.sendium.core.message.StandardMessage;
+import gr.cytech.sendium.core.worker.DlrService;
+import gr.cytech.sendium.core.worker.DlrStorageException;
+import gr.cytech.sendium.core.worker.MessageState;
+import gr.cytech.sendium.external.WorkerResourceProvider;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class StandardSmppServerMessageStoreTest {
+
+ @Mock
+ private SmppServerWorker worker;
+
+ @Mock
+ private WorkerResourceProvider workerResources;
+
+ @Mock
+ private DlrService dlrService;
+
+ private StandardSmppServerMessageStore messageStore;
+
+ @BeforeEach
+ void setUp() {
+ when(worker.getWorkerResources()).thenReturn(workerResources);
+ when(workerResources.isDlrPersistenceEnabled()).thenReturn(true);
+ when(workerResources.getDlrService()).thenReturn(dlrService);
+ when(worker.getMaxRetries()).thenReturn(5);
+ when(worker.isForwardDlrs()).thenReturn(true);
+
+ messageStore = new StandardSmppServerMessageStore(worker);
+ }
+
+ @Test
+ void persistMessages_SavesStatesAsOneBatchBeforeNotifyingWorker() {
+ List> events = new ArrayList<>();
+
+ StandardMessage msg1 = new StandardMessage();
+ msg1.serial = "gw-1";
+ msg1.owner_id = "account1";
+ msg1.systemId = "sys1";
+ msg1.from = "from1";
+ msg1.to = "to1";
+ msg1.acked = true;
+
+ StandardMessage msg2 = new StandardMessage();
+ msg2.serial = "gw-2";
+ msg2.owner_id = "account2";
+ msg2.systemId = "sys2";
+ msg2.from = "from2";
+ msg2.to = "to2";
+ msg2.acked = true;
+
+ InEvent event1 = new InEvent<>(msg1, new SubmitSm(), 1,
+ new Timestamp(System.currentTimeMillis()));
+ InEvent event2 = new InEvent<>(msg2, new SubmitSm(), 2,
+ new Timestamp(System.currentTimeMillis()));
+
+ events.add(event1);
+ events.add(event2);
+
+ messageStore.persistMessages(events);
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ InOrder order = inOrder(dlrService, worker);
+ order.verify(dlrService).saveInitialStates(captor.capture());
+ order.verify(worker).handlePersistedMessages(events);
+ assertEquals("account1", captor.getValue().get(0).getAccountId());
+ assertEquals("sys1", captor.getValue().get(0).getSystemId());
+ assertEquals(MessageState.DeliveryChannel.SMPP, captor.getValue().get(0).getDeliveryChannel());
+ assertEquals("account2", captor.getValue().get(1).getAccountId());
+ assertEquals("sys2", captor.getValue().get(1).getSystemId());
+ assertEquals(MessageState.DeliveryChannel.SMPP, captor.getValue().get(1).getDeliveryChannel());
+ }
+
+ @Test
+ void persistMessages_SavesReassembledPartIds() {
+ StandardMessage msg = new StandardMessage();
+ msg.serial = "gw-1";
+ msg.owner_id = "account1";
+ msg.systemId = "sys1";
+ msg.from = "from1";
+ msg.to = "to1";
+ msg.reassembledParts = new ArrayList<>(List.of("part-1", "part-2"));
+
+ messageStore.persistMessages(List.of(new InEvent<>(msg, null, 1, new Timestamp(System.currentTimeMillis()))));
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(dlrService).saveInitialStates(captor.capture());
+ assertEquals(List.of("part-1", "part-2"), captor.getValue().getFirst().getReassembledParts());
+ }
+
+ @Test
+ void persistMessages_UsesNoneChannelWhenDlrWasNotRequested() {
+ StandardMessage msg = new StandardMessage();
+ msg.serial = "gw-1";
+ msg.systemId = "sys1";
+
+ messageStore.persistMessages(List.of(new InEvent<>(
+ msg, new SubmitSm(), 1, new Timestamp(System.currentTimeMillis()))));
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(dlrService).saveInitialStates(captor.capture());
+ assertEquals(MessageState.DeliveryChannel.NONE,
+ captor.getValue().getFirst().getDeliveryChannel());
+ }
+
+ @Test
+ void persistMessages_UsesNoneChannelWhenDlrForwardingIsDisabled() {
+ when(worker.isForwardDlrs()).thenReturn(false);
+ StandardMessage msg = new StandardMessage();
+ msg.serial = "gw-1";
+ msg.systemId = "sys1";
+ msg.acked = true;
+
+ messageStore.persistMessages(List.of(new InEvent<>(
+ msg, new SubmitSm(), 1, new Timestamp(System.currentTimeMillis()))));
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(dlrService).saveInitialStates(captor.capture());
+ assertEquals(MessageState.DeliveryChannel.NONE,
+ captor.getValue().getFirst().getDeliveryChannel());
+ }
+
+ @Test
+ void durableDlrAttemptsFollowPersistenceBoundary() {
+ when(workerResources.isDlrPersistenceEnabled()).thenReturn(false, true);
+
+ assertFalse(messageStore.tracksDlrDeliveryAttempts());
+ assertTrue(messageStore.tracksDlrDeliveryAttempts());
+ }
+
+ @Test
+ void markAsUnpushedFallsBackWhenPersistenceIsDisabled() {
+ when(workerResources.isDlrPersistenceEnabled()).thenReturn(false);
+ StandardMessage dlr = new StandardMessage();
+ dlr.type = StandardMessage.MSG_DLR;
+
+ assertFalse(messageStore.markAsUnpushed(dlr));
+ }
+
+ @Test
+ void onClientConnected_ReconstructsAndEnqueuesPendingDlrWithoutCompletingIt() throws Exception {
+ MessageState state = pendingState();
+ when(dlrService.listPendingSmppDeliveries("sys1")).thenReturn(List.of(state));
+
+ messageStore.onClientConnected("sys1");
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(StandardMessage.class);
+ verify(worker).enqueue(captor.capture());
+ StandardMessage replay = captor.getValue();
+ assertAll(
+ () -> assertEquals(StandardMessage.MSG_DLR, replay.type),
+ () -> assertEquals("gw-1", replay.serial),
+ () -> assertEquals("destination", replay.from),
+ () -> assertEquals("source", replay.to),
+ () -> assertEquals(StandardMessage.DLR_STAT_UNDELIV, replay.state),
+ () -> assertEquals("42", replay.errcode),
+ () -> assertEquals("account1", replay.owner_id),
+ () -> assertEquals("sys1", replay.systemId),
+ () -> assertEquals(List.of("part-1", "part-2"), replay.reassembledParts));
+ verify(dlrService, never()).completeDelivery(anyString(), anyInt());
+ }
+
+ @Test
+ void onClientConnected_WhenEnqueueFailsRetainsPendingDlr() throws Exception {
+ MessageState state = pendingState();
+ when(dlrService.listPendingSmppDeliveries("sys1")).thenReturn(List.of(state));
+ doThrow(new InterruptedException("queue stopped")).when(worker).enqueue(any());
+
+ messageStore.onClientConnected("sys1");
+
+ verify(dlrService, never()).completeDelivery(anyString(), anyInt());
+ verify(dlrService, never()).retryDelivery(anyString(), anyInt(), anyString(), anyLong());
+ assertTrue(Thread.interrupted());
+ }
+
+ @Test
+ void persistMessages_WithNullMessage_Skips() {
+ List> events = new ArrayList<>();
+
+ InEvent