SMPP Message Cache
Overview
The SMPP Message Cache is a local persistence layer that allows the SMPP gateway to continue accepting inbound messages even when the backend API is unavailable. Messages are cached locally in Mnesia and automatically delivered to the API when connectivity is restored, using intelligent retry logic with exponential backoff.
Features
- Resilient Message Acceptance - Continue accepting SMPP messages during API outages
- Persistent Storage - Uses Mnesia with
:disc_copiesfor durability across restarts - Automatic Retry - Background workers automatically attempt delivery with exponential backoff
- Per-Bind Configuration - Enable/disable caching independently for each SMPP bind
- Overflow Protection - FIFO eviction when cache reaches configured size limit
- Failed Message Retention - Permanently failed messages kept for manual review
- Real-Time Monitoring - LiveView dashboard with cache statistics and metrics
- Prometheus Metrics - Full metrics export for monitoring and alerting
Architecture
Message Flow
With Cache Enabled
SMPP Client → submit_sm → Gateway Server
↓
Cache in Mnesia (immediate response)
↓
CacheFlushWorker (background)
↓
Backend API
With Cache Disabled
SMPP Client → submit_sm → Gateway Server
↓
Backend API (direct)
Components
-
MessageCache Module (
lib/sms_c/smpp/message_cache.ex)- Core caching logic
- Overflow handling
- Query functions for LiveView and workers
-
CacheFlushWorker (
lib/sms_c/smpp/cache_flush_worker.ex)- GenServer per bind with caching enabled
- Polls for messages ready for retry
- Implements exponential backoff
- Marks permanently failed messages
-
Mnesia Table (
:smpp_message_cache)- Persistent storage with
:disc_copies - Indexed by bind_name, next_retry_at, and status
- Survives application restarts
- Persistent storage with
Configuration
Global Settings
Edit config/runtime.exs:
config :omnimessage_smpp,
# How often flush workers poll for messages (milliseconds)
cache_flush_interval: 10_000,
# Maximum retry attempts before marking as failed_permanent
cache_max_retry_attempts: 10,
# Exponential backoff multiplier
cache_backoff_multiplier: 2,
# Mnesia storage type (:disc_copies or :ram_copies)
mnesia_storage_type: :disc_copies
Per-Bind Configuration
Each SMPP bind (client or server) can be configured independently:
config :omnimessage_smpp, :binds, [
%{
name: "my_smpp_bind",
mode: :server,
system_id: "username",
password: "password",
# Cache configuration
cache_enabled: true, # Enable caching (default: false)
cache_max_size: 10_000, # Max messages to cache (default: 10,000)
cache_retry_interval: 60 # Base retry interval in seconds (default: 60)
}
]
Environment Variables
# Global cache settings
CACHE_FLUSH_INTERVAL=10000 # Flush poll interval (ms)
CACHE_MAX_RETRY_ATTEMPTS=10 # Max retries before permanent failure
CACHE_BACKOFF_MULTIPLIER=2 # Exponential backoff multiplier
MNESIA_STORAGE_TYPE=disc_copies # Mnesia storage type
Retry Behavior
Exponential Backoff
When message delivery fails, the retry interval doubles with each attempt:
Base interval: 60 seconds
Backoff multiplier: 2
Retry 0: 60s (1 minute)
Retry 1: 120s (2 minutes)
Retry 2: 240s (4 minutes)
Retry 3: 480s (8 minutes)
Retry 4: 960s (16 minutes)
Retry 5: 1920s (32 minutes)
...
Retry 9: 30,720s (8.5 hours)
Permanent Failure
After 10 failed attempts (by default), messages are marked as failed_permanent and:
- Remain in the cache for manual review
- Stop being retried automatically
- Appear in the "Failed Permanent" section of the cache dashboard
- Can be manually retried or cleared
Status Transitions
:pending → :delivering → SUCCESS (deleted from cache)
→ FAILURE → :pending (retry with backoff)
→ :failed_permanent (after max retries)
Monitoring
LiveView Dashboard
Access the cache dashboard at http://your-server:4000/smpp → "Message Cache" tab
Summary Cards:
- Total Cached - All messages currently in cache
- Pending Delivery - Messages waiting for retry
- Failed Permanent - Messages that exceeded max retries
Per-Bind Table:
- Bind name
- Cached message count
- Pending / Failed breakdown
- Max cache size
- Utilization percentage (with visual progress bar)
Prometheus Metrics
# Current cache size per bind
smpp_cache_size{bind_name="my_bind",mode="server"} 42
# Total successfully delivered messages
smpp_cache_delivered_total{bind_name="my_bind"} 1234
# Total retry attempts
smpp_cache_retry_total{bind_name="my_bind"} 56
# Total permanent failures
smpp_cache_permanent_failures_total{bind_name="my_bind"} 2
# Total overflow events (messages dropped)
smpp_cache_overflow_total{bind_name="my_bind"} 0
Log Messages
INFO Message -123456789 cached for my_smpp_bind
INFO Successfully delivered cached message -123456789, API ID: 99999
WARN Failed to deliver message -123456789 (retry 3/10), next retry at 2026-02-01 12:34:56Z
ERROR Message -123456789 exceeded max retries (10), marking as failed_permanent
WARN Cache overflow for my_smpp_bind: deleted oldest message
Operations
Enable/Disable Caching
Via LiveView UI
- Navigate to
http://your-server:4000/smpp - Go to "Client Peers" or "Server Peers" tab
- Edit the bind
- Toggle "Cache Enabled" checkbox
- Save changes
Via IEx Console
# Enable caching for a bind
SmsC.SMPPConfig.update_server_peer("my_bind", "username", "password",
cache_enabled: true,
cache_max_size: 10_000,
cache_retry_interval: 60
)
# Disable caching
SmsC.SMPPConfig.update_server_peer("my_bind", "username", "password",
cache_enabled: false
)
Monitor Cache Status
# Get global summary
SmsC.SMPP.MessageCache.get_cache_summary()
# => %{total_cached: 42, pending: 40, failed: 2}
# Get per-bind breakdown
SmsC.SMPP.MessageCache.get_cache_by_bind()
# => [
# %{bind_name: "bind1", total: 30, pending: 28, failed: 2, max_size: 10_000},
# %{bind_name: "bind2", total: 12, pending: 12, failed: 0, max_size: 10_000}
# ]
# Count messages for specific bind
SmsC.SMPP.MessageCache.count_cached_messages("my_bind")
# => 42
Manual Interventions
Clear Failed Messages
# Get all failed messages for a bind
{:atomic, failed_messages} = :mnesia.transaction(fn ->
:mnesia.match_object({:smpp_message_cache, :_, "my_bind", :_, :_, :_, :_, :_, :_, :failed_permanent})
end)
# Delete them
Enum.each(failed_messages, fn {_, {bind_name, msg_id}, _, _, _, _, _, _, _, _} ->
SmsC.SMPP.MessageCache.delete_cache_record(bind_name, msg_id)
end)
Force Retry of Failed Message
# Reset a failed_permanent message to pending
SmsC.SMPP.MessageCache.update_cache_record("my_bind", -123456, %{
status: :pending,
retry_count: 0,
next_retry_at: DateTime.utc_now(),
last_error: nil
})
Troubleshooting
Cache Full / Overflow Events
Symptom: cache_overflow_total metric increasing, oldest messages being dropped
Cause: Cache size limit reached
Solutions:
- Increase
cache_max_sizefor the bind - Investigate why API delivery is failing (check API logs, network)
- Manually clear old failed messages
- Check if flush interval is too slow
Messages Not Being Delivered
Symptom: Messages stuck in :pending status
Possible Causes:
-
API is down
- Check API availability
- Verify backend API logs
- Check network connectivity
-
next_retry_at is in the future
- Messages will be retried when
next_retry_atis reached - Check exponential backoff schedule
- Messages will be retried when
-
Flush worker not running
# Check if workers are running
Supervisor.which_children(SmsC.SMPP.Supervisor) -
Cache disabled
- Verify
cache_enabled: truein bind configuration
- Verify
High Retry Counts
Symptom: Many messages with high retry_count values
Investigation:
# Find messages with high retry counts
{:atomic, messages} = :mnesia.transaction(fn ->
:mnesia.match_object({:smpp_message_cache, :_, "my_bind", :_, :_, :_, :_, :_, :_, :_})
end)
high_retry = Enum.filter(messages, fn {_, _, _, _, _, _, retry_count, _, _, _} ->
retry_count >= 5
end)
# Check last_error for each
Enum.each(high_retry, fn {_, _, _, msg_id, _, _, retry_count, _, last_error, _} ->
IO.puts("Message #{msg_id}: #{retry_count} retries, error: #{inspect(last_error)}")
end)
Mnesia Disk Space
Symptom: Disk space filling up
Check Mnesia directory:
ls -lh Mnesia.*
du -sh Mnesia.*
Clean up:
- Clear old failed messages (see Manual Interventions above)
- Reduce
cache_max_sizeper bind - Enable cache overflow (ensure proper FIFO eviction)
Performance Considerations
Memory Usage
- Each cached message uses approximately 500-1000 bytes (depending on message size)
- 10,000 messages ≈ 5-10 MB memory
- With
:disc_copies, data is also written to disk
CPU Usage
- Flush workers poll every 10 seconds by default (configurable)
- Batch processing (100 messages per cycle) reduces overhead
- Concurrent delivery (max 10 concurrent API calls per worker)
Disk I/O
:disc_copieswrites to disk on every transaction- For very high throughput (>1000 msg/sec), consider:
- Using
:ram_copies(loses persistence) - Increasing flush intervals
- Scaling horizontally
- Using
Recommended Limits
| Scenario | cache_max_size | cache_flush_interval |
|---|---|---|
| Low volume (<100 msg/sec) | 10,000 | 10,000ms |
| Medium volume (100-500 msg/sec) | 50,000 | 5,000ms |
| High volume (>500 msg/sec) | 100,000 | 3,000ms |
Recovery Scenarios
Application Restart
- Mnesia automatically loads
:disc_copiestables from disk - Cached messages remain intact
- Flush workers restart and continue processing
Database Migration
When upgrading from a version without cache support:
- Migration automatically adds cache fields to existing binds
- Default values:
cache_enabled: false,cache_max_size: 10_000,cache_retry_interval: 60 - No data loss
- Cache table created on first run
API Outage Recovery
- Messages accumulate in cache during outage
- When API recovers, flush workers automatically deliver
- Oldest messages delivered first (FIFO)
- Exponential backoff prevents API overload during recovery
Best Practices
- Enable Caching by Default - Prevents message loss during outages
- Monitor Metrics - Set up alerts on
cache_permanent_failures_totalandcache_overflow_total - Size Appropriately - Set
cache_max_sizebased on expected outage duration - Review Failed Messages - Regularly check failed_permanent messages for patterns
- Test Failover - Simulate API outages to verify cache behavior
- Adjust Retry Intervals - Tune based on API recovery time patterns
- Use Persistent Storage - Keep
mnesia_storage_type: disc_copiesin production