SOLAPI SMS Rollout
Context
The notification module started with queued tenant-scoped SMS history and scheduling, then expanded into a live provider rollout on June 28, 2026. The main steps were:
3289196c: add provider dispatch, scheduler flow, webhook intake, andsms_messagesprovider-tracking columns.afab0183,e73277f2, and04aae48c: harden the schema rollout after index and migration-order issues.4de6a6c7: repair the transaction boundary so post-commit dispatch and webhook writes still bindapp.tenant_idunder RLS.03981d38,01ad30b2, and13852f8d: remove a SOLAPI-specific LMS subject mismatch, accept webhook payloads that omitgroupId, and switch live sends from global config to tenant-owned sender numbers.
The steady-state contract now lives in the Notification Service reference page.
What Broke Or Changed
The rollout was not just "turn on SOLAPI." It changed three contracts at once:
- outbound send execution moved from a single in-transaction queue write to after-commit provider dispatch in
SmsDispatchService; - provider state became part of the persisted message contract through
V76__sms_provider_dispatch_tracking.sql; - live sender identity moved from app config to tenant-scoped
sms_sender_settingsinV78__sms_sender_settings.sql.
That exposed failure modes that the queued MVP did not have:
- two dispatch workers could race the same
PENDINGrow unless claim state was persisted first; - a
REQUIRES_NEWtransaction could lose the original tenant binding and read an empty RLS view; - SOLAPI webhook payloads were not perfectly stable, especially around
groupIdand LMS-only fields; - a global
SOLAPI_FROM_NUMBERwas not enough once one backend served many tenants.
Diagnosis And Decision Ladder
- The first live-provider pass added provider columns and a scheduler, but it still assumed the same transaction and tenant context would naturally survive into post-commit work.
- Hardening moved dispatch from "load row, then mutate" to "claim first, then load" so retries and concurrent schedulers could not double-send the same message.
- The migration plan briefly split the provider indexes into
V77withCREATE INDEX CONCURRENTLY, then backed away after rollout pain. Current source keepsV76as the real index-bearing migration and leavesV77as a transactional no-op. - The decisive fix was to stop relying on the declarative RLS aspect for programmatic transactions.
SmsDispatchServicenow runs claim, result, failure, and webhook writes through a dedicated@RequiresNewTransactiontemplate and explicitly binds the tenant before repository access. - Once tenant-owned sender numbers arrived, live SOLAPI dispatch stopped reading a single app-level from-number and instead resolved the saved message sender or the tenant's approved fallback.
Current dispatch writes depend on this explicit binder path:
// lumie-backend/modules/notification/src/main/java/com/lumie/notification/application/service/SmsDispatchService.java
private <T> T executeDispatchTransaction(TransactionCallback<T> callback) {
return dispatchTransactionTemplate.execute(status -> {
rlsTenantTransactionBinder.bindRequiredTenantToCurrentTransaction();
return callback.doInTransaction(status);
});
}
Resolution
The current rollout shape is stable because the code now separates concerns clearly:
SmsDispatchServiceclaims work, performs provider I/O outside the transaction, then persists the outcome in a fresh tenant-bound transaction.RlsTenantTransactionBinderbindsapp.tenant_iddirectly onto the JDBC connection used by that transaction.SolapiSmsWebhookControllerandSmsWebhookServicerehydrate tenant context from callbackcustomFields, thenSmsDispatchServicevalidates message id, provider message id, and provider group id before mutating history.SolapiSmsProviderAdapterno longer sends the LMSsubjectfield and rejects live sends that do not have a resolved sender number.V78__sms_sender_settings.sqladds the tenant RLS table that owns approved sender numbers, whileSmsMessageResponseexposes the savedsenderNumberso history reflects the real dispatch identity.
The current source-of-truth files are:
lumie-backend/modules/notification/src/main/java/com/lumie/notification/application/service/SmsDispatchService.javalumie-backend/modules/notification/src/main/java/com/lumie/notification/application/service/SmsWebhookService.javalumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/out/external/SolapiSmsProviderAdapter.javalumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.javalumie-backend/app/src/main/resources/db/migration/public/V76__sms_provider_dispatch_tracking.sqllumie-backend/app/src/main/resources/db/migration/public/V77__sms_provider_dispatch_tracking_indexes.sqllumie-backend/app/src/main/resources/db/migration/public/V78__sms_sender_settings.sql
Prevention
- Treat provider rollout work as a cross-cutting change across queueing, tenancy, schema, and callback contracts, not as an adapter-only feature.
- Keep live-provider writes on explicit transaction infrastructure. Programmatic
TransactionTemplateflows do not get the RLS aspect for free. - Preserve message-level provider state in the database before trusting asynchronous callbacks.
- Prefer tenant-owned sender settings over global SMS sender config. Current
application.yamlexposeslumie.sms.solapi.{api-key,api-secret,base-url,webhook-token}only;SOLAPI_FROM_NUMBERis no longer part of the runtime contract. - When webhook providers send inconsistent payloads, validate bindings defensively and allow the minimum backward-compatible fallback the stored row can prove. The current code accepts missing
providerGroupIdonly when the message identity still matches.