Skip to content

db_redis: new Redis (server/cluster) DB backend module - #4111

Open
Lt-Flash wants to merge 8 commits into
OpenSIPS:masterfrom
Lt-Flash:feature/db-redis-devel
Open

Lt-Flash wants to merge 8 commits into
OpenSIPS:masterfrom
Lt-Flash:feature/db-redis-devel

Conversation

@Lt-Flash

@Lt-Flash Lt-Flash commented Jul 15, 2026 •

Copy link
Copy Markdown

Overview

This PR adds db_redis, a new optional OpenSIPS DB API backend that stores data in a Redis server or a Redis Cluster. Unlike cachedb_redis (which exposes Redis through the key-value NoSQL interface), db_redis presents Redis as a relational-style backend behind the standard db_url mechanism: a module is switched to Redis simply by pointing its db_url at a redis:// URL — no code change in the consuming module.

The module is excluded from the default build (it depends on hiredis) and is enabled by removing db_redis from exclude_modules, exactly like the other library-dependent DB drivers.

What the module covers

Data model — Redis is schemaless, so table layouts are declared out of band as Redis hashes:

  • schema:<table> — ordered column list (__cols), primary key (__pk), and one type[,null][,auto] field per column (types: int, bigint, double, string, blob, datetime).
  • <table>:<pk> — one row per key, one hash field per non-NULL column. Blobs are stored binary-safe (no encoding overhead); NULL is the absence of a field.
  • seq:<table> — INCR counter backing auto-increment primary keys and last_inserted_id.
  • The standard version table is provisioned like any other table.

Ready-to-load provisioning files ship under scripts/redis/.

Structured DB API implemented — query (primary-key fast path + full-table scan-and-filter, all six operators = < > <= >= != combined with AND/OR, single-column ORDER BY), insert (HSETNX row reservation for SQL-insert uniqueness), update (HSET/HDEL), delete (incl. bulk OR primary-key deletes and truncate), replace, insert_update, last_inserted_id. Typed results are produced from the declared schema.

Dual transport mode, auto-detected at startup via INFO CLUSTER:

  • Single endpoint — a standalone server, or the current master reached through an external load balancer / Sentinel setup. Reconnects transparently on I/O errors and on -READONLY replies (a demoted master after failover).
  • Redis Cluster — starting from a single seed host, the module discovers the full topology (CLUSTER SHARDS, falling back to CLUSTER SLOTS) and keeps a warm connection open to every master shard, so any key can be served immediately without a new TCP handshake on redirect. Keys are routed by CRC16 hash slot (honouring {hash tags}); MOVED is served from the warm pool with a deferred topology refresh, ASK is followed with a one-shot ASKING, and TRYAGAIN is retried after a short back-off. Full-table operations scan every master and merge. Only masters are used (read-your-writes consistency).

The mode can be pinned with the mode modparam (auto | single | cluster), turning a misconfiguration into a clear startup error instead of silently wrong behaviour.

Modules supported in this PR

Module Table(s) Status
dialog dialog (v12) Provisioning + verified end-to-end (table-version check, full load, shutdown flush) — including against a live 3-master Redis Cluster
dispatcher dispatcher (v9) Provisioning + verified end-to-end (destination load & activation)
usrloc location (v1013) Provisioning + code change (see below) + verified end-to-end (contact load in sql-only mode)
clusterer clusterer (v4), clusterer_bridge (v1) Provisioning + schema-validated

usrloc change

usrloc had a single raw SQL query (get_domain_db_ucontacts, used to fetch live contacts for NAT pinging) that excluded any backend without raw-query support. This PR adds a structured-query fallback: when the bound DB module lacks DB_CAP_RAW_QUERY, the query is built through the structured API (column projection + expires > now) and the contact_id modulo partitioning is applied client-side while iterating rows. SQL backends keep the exact same raw-query path, so there is no behaviour change for existing setups — and this also makes usrloc work with db_text. Consequently DB_CAP_RAW_QUERY is no longer a hard requirement in sql-only mode.

Roadmap

db_redis module

  • Phase 1 (this PR) — structured DB API, dual single/cluster transport, schema provisioning.
  • Phase 2 — secondary indexes for non-primary-key lookups (index sets, or RediSearch ON HASH where available), FETCH result cursors, per-node pipelining for bulk operations.
  • Phase 3 — optional SQL-subset raw_query, a dbschema generation target to emit schema:<table> files from db/schema/*.xml, async operations.

Module coverage

Supported now: dialog, dispatcher, usrloc, clusterer.

Ready with only a provisioning file (structured DB API, no code change needed): the large majority of DB consumers, e.g. acc, alias_db, auth_db, domain, domainpolicy, permissions, group, speeddial, dialplan, load_balancer, mqueue, msilo, ratelimit, uac_registrant, userblacklist, tls_mgm, b2b_entities / b2b_logic / b2b_sca, presence / presence_xml / rls / pua, call_center, imc, tracer, trie, qrouting, rate_cacher, emergency, fraud_detection, cpl_c, xcap / xcap_client. These only need their schema hashes provisioned.

Needs a per-module structured fallback (raw SQL today, like usrloc did): drouting, carrierroute, auth_jwt, sipcapture.

Not applicable:

  • sqlops — executes arbitrary user-supplied SQL, which cannot be translated to Redis operations.
  • cachedb_* — these serve the NoSQL cache interface rather than the structured DB API, so they are consumers of cachedb_redis, not of this module. One exception: cachedb_perf (cachedb_perf: high-performance local memory cache built on modern kernel features #4118) also persists whole collections through the structured DB API (perf_save / perf_load, db_mode), so it is a genuine db_redis consumer — verified end to end against this module, with its cachedb_perf table (collection, pkey, pvalue, expires) provisioned like any other. Note Redis takes no transaction, so that snapshot is not atomic the way it is on an SQL backend.
  • db_virtual, db_cachedb, cachedb_sql, sql_cacher — DB-layer plumbing, not end consumers.
  • tm — keeps no database table.

Testing

  • db_redis builds cleanly; module loads and binds via db_bind_api.
  • Verified end-to-end against redis-server (standalone) and a live 3-master / 3-replica Redis 8 cluster: seeded with a single node, all masters are discovered and their connections warmed up; dialog rows load and flush across non-seed masters via the slot map.
  • dispatcher and usrloc load their tables end-to-end through db_redis.
  • All shipped schemas pass a column/type-field consistency check.

Notes

  • New module modules/db_redis/ with DocBook documentation and generated README.
  • New provisioning files under scripts/redis/.
  • db_redis added to exclude_modules in Makefile.conf.template (optional, off by default).

Lt-Flash added 6 commits July 15, 2026 15:07
Implements the OpenSIPS db_api on top of Redis, storing rows as hashes
at <table>:<pk> with driver-side schemas provisioned as schema:<table>
hashes (see scripts/redis/README).

Phase 1 scope:
- structured ops: query (pk fast path + scan-filter, all operators,
  single-column ORDER BY), insert, update, delete (incl. bulk OR
  pk-deletes and truncate), replace, insert_update, last_inserted_id
  (auto-increment emulated via INCR seq:<table>)
- dual transport mode, auto-detected per connection via CLUSTER INFO:
  single endpoint (standalone or LB-fronted sentinel master, with
  READONLY-triggered reconnect) and Redis Cluster (topology from
  CLUSTER SHARDS with CLUSTER SLOTS fallback, eager connections to all
  masters, MOVED served from the warm pool, ASK/TRYAGAIN handling,
  deferred topology refresh)
- typed results from declared schemas (int/bigint/double/string/blob/
  datetime), binary-safe blobs, NULL as absent hash field

The module is optional and excluded from the default build (needs
hiredis); enable by removing db_redis from exclude_modules.

Verified against redis-server 5.x with the dialog module in db_mode 1:
table version check, full-table load of seeded dialogs, and the
shutdown update flush.
The cluster probe queried CLUSTER INFO for cluster_enabled, but that
field is only reported by the general INFO command (Cluster section),
not by CLUSTER INFO - so a real cluster was misdetected as a single
endpoint. Probe with INFO CLUSTER instead, which reliably reports
cluster_enabled:0/1 on both standalone and cluster instances.

Also log a one-line summary of the discovered master pool and its warm
connection count after cluster topology load (per-master detail at
debug level).

Verified against a live 3-master/3-replica Redis 8 cluster: seeded with
a single node, all three masters are discovered and their TCP
connections warmed up, and dialog rows load and flush across the
non-seed masters via the slot map.
DocBook admin guide covering the data model (schema/row/sequence
hashes), the single-endpoint and Redis Cluster deployment modes, the
exported parameters, usage examples and limitations, plus the generated
README and contributors file.
Adds ready-to-load schema definitions matching the canonical SQL
layouts:
- dispatcher (dispatcher table, v9)
- usrloc (location table, v1013)
- clusterer (clusterer v4, clusterer_bridge v1)

Each file is self-contained and also provisions the version-table
schema. The tm module keeps no DB table, so it has none. usrloc's
raw-query paths remain unsupported; the structured contact path works.

Verified: all schemas pass the column/type-field consistency check, and
the dispatcher table loads end-to-end through db_redis (schema parsed,
destination row read and activated).
get_domain_db_ucontacts() used a raw SQL query (the only raw_query in
usrloc) to fetch all live contacts, which excluded DB backends that do
not implement raw queries, such as db_redis.

When the bound DB module lacks DB_CAP_RAW_QUERY, build the equivalent
query through the structured DB API (column projection plus an
'expires > now' filter) and apply the contact_id modulo partitioning
client-side while iterating the rows. SQL backends keep the exact same
raw-query path as before, so there is no behaviour change for existing
setups; this also makes the code work with db_text.

Consequently DB_CAP_RAW_QUERY is no longer a hard requirement in
sql-only mode.

Verified: usrloc in sql-only mode over db_redis loads the location
schema and returns live contacts to nathelper for pinging.
Update the module limitations and the scripts/redis README now that
usrloc no longer depends on raw queries, and list dispatcher, clusterer
and usrloc among the supported consumers.
@Lt-Flash
Lt-Flash marked this pull request as ready for review July 15, 2026 05:57
Marshal DB values through the db_ut helpers (db_str2int/bigint/double,
db_int2str/bigint2str/double2str) instead of raw strtoll/snprintf, the
same way db_sqlite and the other DB drivers do. This keeps type
conversion and its error handling consistent across the DB backends.

No functional change: the memory model was already correct (per-process
pkg allocations, pool_con-compatible connection header, driver-owned
result values freed via VAL_FREE).
@Lt-Flash

Copy link
Copy Markdown
Author

Refactored the module to comply with OpenSIPS conventions after an internal review of the memory model and DB API usage:

  • Value marshalling now goes through the DB layer helpers (db_str2int/db_str2bigint/db_str2double and db_int2str/db_bigint2str/db_double2str) instead of raw strtoll/strtod/snprintf, matching how db_sqlite and the other DB backends convert values — so type conversion and its error handling are consistent across drivers.

The rest of the model was already compliant and was verified during the review:

  • All allocations use the OpenSIPS package memory API (pkg_malloc/pkg_free/pkg_realloc); no raw libc allocation. pkg is correct here since DB connections are per-worker (each process opens its own in child_init), exactly like db_mysql/db_sqlite.
  • The connection struct header matches struct pool_con field-for-field, so the core connection pool (db_do_init/db_do_close) manages it correctly.
  • Result values are handed off with VAL_FREE set so the core's db_free_row reclaims driver-allocated DB_STR/DB_BLOB buffers; schema-owned column names are (correctly) not freed.
  • Uses the core DB entry points (db_do_init, db_do_close, db_use_table) and honors CON_OR_OPERATOR/CON_OR_RESET.

Remaining libc usage is limited to non-DB-boundary work (internal numeric comparison in filter/sort, and Redis-protocol parsing such as MOVED/ASK and SCAN cursors), where the DB helpers do not apply.

Regression-tested after the change: dialog load + shutdown flush over db_redis remains clean.

@github-actions

Copy link
Copy Markdown

Any updates here? No progress has been made in the last 30 days, marking as stale.

@github-actions github-actions Bot added the stale label Aug 26, 2026
@Lt-Flash

Copy link
Copy Markdown
Author

Waiting for review by mainteiner

@stale stale Bot removed the stale label Aug 26, 2026
@github-actions

Copy link
Copy Markdown

Any updates here? No progress has been made in the last 30 days, marking as stale.

@github-actions github-actions Bot added the stale label Sep 25, 2026
@Lt-Flash

Copy link
Copy Markdown
Author

Still waiting for review

@stale stale Bot removed the stale label Sep 26, 2026
@razvancrainea razvancrainea self-assigned this Oct 6, 2026
@razvancrainea

Copy link
Copy Markdown
Member

I am wondering what is the real benefit of using this module, instead of db_cachedb with cachedb_redis. If there are big differentces, isn't it a better idea to integrate them in the existing modules, rather than adding a new one?

Best regards,
Răzvan

@Lt-Flash

Lt-Flash commented Oct 6, 2026

Copy link
Copy Markdown
Author

Hi Răzvan,

Thank you for the review.

The separation was deliberate: I meant this as a plain db_api driver in the same role as db_mysql or db_sqlite - same front API, same relational row shape, with the storage simply living in Redis. Rows are native Redis hashes (HSET <table>:<pk>, one field per column) described by a per-table schema, so results come back as properly typed db_res_t, and the driver is cluster-aware for the DB workload (slot map discovered at startup, connections to all masters, MOVED/ASK followed). It has been exercised with dialog, dispatcher, usrloc and cachedb_perf.

On that specific combination: unless I am misreading master, it cannot run today - db_cachedb delegates to the backend's db_*_trans callbacks and only cachedb_mongodb implements them, so cachedb_redis would fail at the first query. I mention that only as the current state; your underlying question stands either way.

And I think it is the right question. The part I cannot argue with is duplication: a separate driver means the Redis connection, cluster topology and MOVED/ASK code exist twice in the tree.

Three shapes, and I am happy with any:

  1. Standalone db_redis (this PR) - a db_api driver beside db_mysql/db_sqlite; cost is the duplicated Redis transport.
  2. Fold it into cachedb_redis - implement the five db_*_trans callbacks there and close this PR; one Redis module, but the cache driver takes on typed schemas and column queries too.
  3. Middle road - keep the mapping engine separate but let cachedb_redis own the transport, so the Redis connection code exists once.

You know the module layout far better than I do, so I will gladly follow whichever you prefer - including dropping this in favour of integration. Just say which and I will rework it.

Best regards,
Yury

@razvancrainea

Copy link
Copy Markdown
Member

Hi, Yuri!

Thanks a lot for your work! If possible, I would go with option 2, as that brings more flexibility to whatever redis operations we have, whether they are DB based or Cache based. If that's not possible, or it introduces a huge performance penalty, let's find a middle road - perhaps db_cachedb can advertise a different way to use it, or the other way around, based on capabilities.

Best regards,
Răzvan

@Lt-Flash

Lt-Flash commented Oct 7, 2026

Copy link
Copy Markdown
Author

Hi Răzvan,

Thanks - that is a clear steer, and I am happy to reshape it into option 2. I will rework this PR in place rather than open a new one.

Two things I ran into while planning it, both in db_cachedb rather than in Redis, and I would value your view before I start:

  1. Capabilities that cannot cross db_cachedb. db_cachedb_bind_api() exports a fixed set (use_table/init/close/query/free_result/insert/delete/update/raw_query) and the core derives the caps from those pointers, so a backend behind it can only ever be QUERY|INSERT|DELETE|UPDATE|RAW_QUERY. This module currently also advertises LAST_INSERTED_ID, REPLACE and INSERT_UPDATE, and there is no *_trans callback for any of them. Should db_cachedb grow them, or are you happy for those three to be dropped on the Redis path?

  2. db_cachedb advertises RAW_QUERY but always fails it. db_cachedb_raw_query() returns -1 unconditionally, yet because the pointer is non-NULL the core sets DB_CAP_RAW_QUERY. A consumer therefore takes the raw path and fails at runtime - this already affects cachedb_mongodb. It also matters here: the usrloc fallback in this PR only engages when the driver lacks that capability. Not exporting raw_query (or exporting it per backend) would fix both, and it sounds close to the capability-based advertising you had in mind.

Otherwise the port looks straightforward: the row/schema mapping and query tiers move across as the five callbacks, and cachedb_redis takes over the connection and cluster layer, which already does topology and MOVED. The one thing I will measure before settling is the connection warm-up - this module opens connections to every master in child_init so a redirect is served on a warm socket, whereas cachedb_redis connects lazily.

Best regards,
Yury

rdb_apply_update() declared its HSET argv/argvlen pair as variable-length
arrays sized from _un, the caller's SET column count: 32 bytes of stack per
column, from a number the function's contract does not bound. The sibling
rdb_store_row() has always pkg_malloc'd the identical pair, so the heap is
this file's own convention.

The arrays are allocated once in db_redis_update(), next to the existing
numbufs and sharing its failure path and free, because the row-scan path
calls rdb_apply_update() once per matched row.

This is not a crash that can be triggered today: through sqlops the column
count is limited by pv_print_buf_size to a few thousand columns, and every
other dbf.update() caller in the tree passes a fixed column array. It is
fixed because an unbounded stack allocation in a driver entry point is wrong
regardless of today's callers.
@Lt-Flash

Copy link
Copy Markdown
Author

I pushed d9e527f (db_redis: size the update argv on the heap, not the stack).

rdb_apply_update() declared its HSET argv/argvlen pair as variable-length arrays sized from _un, the number of SET columns, so its stack use grew with a count the function does not bound (32 bytes per column). The pair is now pkg_malloc()'d once per update in db_redis_update(), next to numbufs and sharing its error path and free, and reused for every matched row. That is how rdb_store_row() already allocates its own argv/argvlen.

It is not a crash anyone can trigger today: through sqlops, pv_print_buf_size limits an update to a few thousand columns, and every other dbf.update() caller in the tree passes a fixed column array. I changed it because a driver entry point should not size its stack from an unbounded caller-supplied count.

The module builds with -Werror on gcc 14, and no variable-length arrays are left in modules/db_redis.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants