kuzaxak created an issue (kamailio/kamailio#4804)

### Description

With `db_mode=1` (`DB_MODE_REALTIME`), the dialog row INSERTed when a dialog 
reaches state 3 (`DLG_STATE_CONFIRMED_NA`, 200 OK received, ACK pending)
always carries a `timeout` column value in the past.

In `dlg_onreply()` the DB write runs **before** the dialog timer is armed
(`src/modules/dialog/dlg_handlers.c:545-552` on branch 6.0, same on master).
The stored column is computed as
`(unsigned int)time(0) + cell->tl.timeout - get_ticks()`
(`dlg_db_handler.c:900-901`), and `tl.timeout` is only assigned inside
`insert_dlg_timer()` (`tl->timeout = get_ticks() + interval`, `dlg_timer.c`).
At the moment of the write `tl.timeout` is still `0`, so the stored value is
`time(0) - get_ticks()` — always `<= now` (observed from hours to years in the
past depending on the internal tick counter).

The row only becomes valid when the **same instance** later processes the ACK
(the 3→4 transition rewrites state and timeout with the timer armed). Until
then the row looks expired to everyone else. Two reproduced consequences:

1. **Multi-instance shared dialog DB** (active-active, in-dialog takeover via
   `dlg_db_load_callid()`): if the ACK — or any in-dialog request — lands on
   an instance that did not create the dialog, that instance loads the row;
   `load_dialog_info_from_db()` sees `timeout <= time(0)`, sets
   `lifetime = 0` (`dlg_db_handler.c:438-447`) and arms a 0-second timer, so
   the dialog expires on the next `dlg_timer_routine` tick (1 Hz,
   `dialog.c:715`): `dlg_ontimeout()` (`dlg_handlers.c:1679`) → state DELETED
   → `destroy_dlg()` → `remove_dialog_from_db()` (`dlg_hash.c:370-371`)
   **deletes the shared row while the call is established**. Runtime-loaded
   dialogs have `dflags = 0`, so nothing guards the DB delete. Every later
   in-dialog request that needs DB recovery (re-INVITE, BYE) then fails on
   every instance — the call can no longer be managed.
   Additionally, if the ACK is processed by a non-creating instance, the
   creating instance never runs the 3→4 repair, so the row stays expired for
   the whole call even if it survives.

2. **Single instance**: a restart in the 200 OK → ACK window loads the dialog
   at startup as already expired; it is destroyed and its row deleted instead
   of recovered.

Expected behavior: the row written at `CONFIRMED_NA` carries
`timeout = now + dlg->lifetime`.

### Troubleshooting

#### Reproduction

Two kamailio instances **A** and **B** sharing one PostgreSQL dialog DB, both
with:

```
loadmodule "dialog.so"
modparam("dialog", "db_url", "postgres://kamailio:kamailiorw@pg:5432/kamailio")
modparam("dialog", "db_mode", 1)
```

A is a plain proxy doing `dlg_manage()` + `record_route()` on the initial
INVITE. B only handles in-dialog requests and recovers unknown dialogs:

```
if (!is_known_dlg()) {
    dlg_db_load_callid("$ci");
    if (!is_known_dlg()) {
        sl_send_reply("500", "Dialog state lost");
        exit;
    }
}
```

Steps:

1. Send INVITE through A; UAS answers 200 OK. Do **not** ACK yet.
2. Probe the row:
   `SELECT state, timeout, timeout - extract(epoch from now())::int AS delta
   FROM dialog WHERE callid='...';`
   → `state=3`, `delta` **negative** (already expired).
3. Send the ACK (or any in-dialog request) to **B**. B logs that the dialog
   was loaded, then within ≤1 s `dlg_ontimeout()` fires and the row is
   **deleted** (the SELECT returns nothing, while the call is still up and
   instance A still holds the dialog in memory).
4. A subsequent BYE to B cannot be recovered (`dlg_db_load_callid()` finds
   nothing).

Control experiment: sending the ACK to **A** instead repairs the row
(`state=4`, `delta=+43199` = `default_timeout`), confirming the creating
instance's ACK is the only repair path.

I have a self-contained docker-compose reproduction (2× kamailio + postgres +
a python UAC/UAS driver with 7 assertions) and can attach it or push it to a
repo on request. With the one-line reorder below applied, all 7 assertions
pass; unpatched, 3 fail exactly as described.

#### Debugging Data

DB observations from the reproduction (fresh containers, kamailio built from
branch 6.0, git `5709418e6a`):

```
-- between 200 OK and ACK (state CONFIRMED_NA):
state=3  timeout=1728115743  now=1782980522  delta=-54864779s   (in the past)

-- after the creating instance processes the ACK (3->4 repair):
state=4  timeout=1783023722  now=1782980523  delta=+43199s      (= 
default_timeout)

-- 0.5s after an in-dialog request was handled by the OTHER instance:
row deleted (call still established, creating instance still has the dialog in 
memory)
```

#### Log Messages

Instance B (the non-creating instance), unpatched — dialog loaded, then
destroyed by the next timer tick, then recovery of the follow-up BYE fails:

```
NOTICE: <script>: B: ACK 599e748deee540e5b76baa41835bcf71@repro not in memory - 
calling dlg_db_load_callid
NOTICE: <script>: B: dialog loaded from DB for ACK 
599e748deee540e5b76baa41835bcf71@repro - is_known_dlg() now true
WARNING: dialog [dlg_handlers.c:1679]: dlg_ontimeout(): dlg timeout - callid: 
'599e748deee540e5b76baa41835bcf71@repro' tags: 'uac-4ecf4ed5' 'uas-98b61cec' 
ostate: 3
NOTICE: <script>: B: BYE 599e748deee540e5b76baa41835bcf71@repro not in memory - 
calling dlg_db_load_callid
ERROR: <script>: B: RECOVERY FAILED - no dialog in DB for BYE 
599e748deee540e5b76baa41835bcf71@repro
```

#### SIP Traffic

Attached `kamailio-dlg-db-mode1-pcaps.zip` with two captures of the identical
scenario (captured on the docker bridge, so all legs are visible):

- `sip-unpatched.pcap` — kamailio built from 6.0 `5709418e6a` unpatched
- `sip-patched.pcap` — same build plus the reorder from Possible Solutions

Address legend: `172.28.0.11` = instance A (creates the dialog),
`172.28.0.12` = instance B (loads it from DB), `151.101.2.132` = test UAC on
the docker host (NATed onto the bridge), `192.168.65.254` = test UAS on the
docker host.

`sip-unpatched.pcap` (tshark summary, annotated):

```
# call 1 (control) - single instance, everything through A:
    1   0.000000 151.101.2.132 → 172.28.0.11  SIP/SDP Request: INVITE 
sip:[email protected]
    5   0.005517  172.28.0.11 → 151.101.2.132 SIP/SDP Status: 200 OK (INVITE)
    6   0.438799 151.101.2.132 → 172.28.0.11  SIP Request: ACK        <- A 
repairs the row (3->4)
# call 2 (incident) - same INVITE path through A:
    8   1.076994 151.101.2.132 → 172.28.0.11  SIP/SDP Request: INVITE 
sip:[email protected]
   12   1.078743  172.28.0.11 → 151.101.2.132 SIP/SDP Status: 200 OK (INVITE)
# DB row now: state=3, timeout in the past (see Debugging Data)
   13   1.501259 151.101.2.132 → 172.28.0.12  SIP Request: ACK        <- to B: 
dlg_db_load_callid(),
                                                                         0s 
timer armed, row DELETED
                                                                         within 
1s (no SIP visible)
   14   5.271345 151.101.2.132 → 172.28.0.12  SIP Request: BYE        <- to B: 
nothing left to load
   15   5.272427  172.28.0.12 → 151.101.2.132 SIP Status: 500 Dialog state lost
# control: A still holds the dialog in memory, BYE through A works:
   16   5.272759 151.101.2.132 → 172.28.0.11  SIP Request: BYE
   19   5.273336  172.28.0.11 → 151.101.2.132 SIP Status: 200 OK (BYE)
```

`sip-patched.pcap` is identical up to frame 14; with the fix B keeps the
loaded dialog and frame 15 becomes `200 OK` instead of
`500 Dialog state lost`, and the shared DB row survives.

[kamailio-dlg-db-mode1-pcaps.zip](https://github.com/user-attachments/files/29589960/kamailio-dlg-db-mode1-pcaps.zip)

### Possible Solutions

Arm the dialog timer before the realtime DB write in the `CONFIRMED_NA` block
of `dlg_onreply()`, so the persisted timeout is `time(0) + lifetime`.
`dlg->lifetime` is already set on the INVITE path (`dlg_new_dialog()`), so the
reorder is safe. Verified against the reproduction above (stored timeout
becomes `now + default_timeout`; a second instance loading the dialog mid-call
keeps it; no row deletion; in-dialog requests recover fine).

```diff
--- a/src/modules/dialog/dlg_handlers.c
+++ b/src/modules/dialog/dlg_handlers.c
@@ -542,13 +542,6 @@ static void dlg_onreply(struct cell *t, int type, struct 
tmcb_params *param)
                /* set start time */
                dlg->start_ts = (unsigned int)(time(0));
 
-               /* save the settings to the database,
-                * if realtime saving mode configured- save dialog now
-                * else: the next time the timer will fire the update*/
-               dlg->dflags |= DLG_FLAG_NEW;
-               if(dlg_db_mode == DB_MODE_REALTIME)
-                       update_dialog_dbinfo(dlg);
-
                if(0 != insert_dlg_timer(&dlg->tl, dlg->lifetime)) {
                        LM_CRIT("Unable to insert dlg %p [%u:%u] on event %d 
[%d->%d] "
                                        "with clid '%.*s' and tags '%.*s' 
'%.*s'\n",
@@ -561,6 +554,18 @@ static void dlg_onreply(struct cell *t, int type, struct 
tmcb_params *param)
                        dlg_ref(dlg, 1);
                }
 
+               /* save the settings to the database,
+                * if realtime saving mode configured- save dialog now
+                * else: the next time the timer will fire the update.
+                * must run after insert_dlg_timer() - the stored timeout is
+                * computed from dlg->tl.timeout, which is 0 until the timer is
+                * armed, so writing earlier persists a timeout in the past and
+                * another instance loading the row (e.g. dlg_db_load_callid())
+                * expires the dialog immediately and deletes the shared row */
+               dlg->dflags |= DLG_FLAG_NEW;
+               if(dlg_db_mode == DB_MODE_REALTIME)
+                       update_dialog_dbinfo(dlg);
+
                /* dialog confirmed (ACK pending) */
                run_dlg_callbacks(
                                DLGCB_CONFIRMED_NA, dlg, req, rpl, 
DLG_DIR_UPSTREAM, 0);
```

I can open a pull request with this patch.

### Additional Information

- [x] LLM/AI Assistants were involved in discovery, analysis or submitting of 
the issue
- [x] It happened in a production deployment
- [ ] It happened in a testing or development deployment
- [ ] Testing or fuzzing tools were used to generate SIP traffic when it 
happened

  * **Kamailio Version** - output of `kamailio -v`

```
version: kamailio 6.0.7 (aarch64/linux)
flags: USE_TCP, USE_TLS, USE_SCTP, TLS_HOOKS, USE_RAW_SOCKS, DISABLE_NAGLE, 
USE_MCAST, DNS_IP_HACK, SHM_MMAP, PKG_MALLOC, MEM_JOIN_FREE, Q_MALLOC, 
F_MALLOC, TLSF_MALLOC, DBG_SR_MEMORY, FAST_LOCK-ADAPTIVE_WAIT-NOSMP, 
USE_DNS_CACHE, USE_DNS_FAILOVER, USE_NAPTR, USE_DST_BLOCKLIST, HAVE_RESOLV_RES, 
TLS_PTHREAD_MUTEX_SHARED
ADAPTIVE_WAIT_LOOPS 1024, MAX_RECV_BUFFER_SIZE 262144, MAX_SEND_BUFFER_SIZE 
262144, MAX_URI_SIZE 1024, BUF_SIZE 65535, DEFAULT PKG_SIZE 8MB
poll method support: poll, epoll_lt, epoll_et, sigio_rt, select.
compiled on 08:20:43 Jul  2 2026 with gcc 14.2.0
```

* **Operating System**:

<!--
Details about the operating system, the type: Linux (e.g.,: Debian 8.4, Ubuntu 
16.04, CentOS 7.1, ...), MacOS, xBSD, Solaris, ...;
Kernel details (output of `lsb_release -a` and `uname -a`)
-->

```
Debian 13.5 (trixie) containers (docker compose), PostgreSQL 17
Linux 6.10.14-linuxkit aarch64 GNU/Linux
```


-- 
Reply to this email directly or view it on GitHub:
https://github.com/kamailio/kamailio/issues/4804
You are receiving this because you are subscribed to this thread.

Message ID: <kamailio/kamailio/issues/[email protected]>
_______________________________________________
Kamailio - Development Mailing List -- [email protected]
To unsubscribe send an email to [email protected]
Important: keep the mailing list in the recipients, do not reply only to the 
sender!

Reply via email to