feat(realtime): track presence with a per-socket connections counter by ArnabChatterjee20k · Pull Request #14606 · appwrite/appwrite · GitHub
Skip to content

feat(realtime): track presence with a per-socket connections counter - #14606

Merged
ArnabChatterjee20k merged 37 commits into
mainfrom
fix-realtime-presence-connections-counter
Oct 21, 2026
Merged

ArnabChatterjee20k merged 37 commits into
mainfrom
fix-realtime-presence-connections-counter

Conversation

@ArnabChatterjee20k

@ArnabChatterjee20k ArnabChatterjee20k commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Issue

Fixes #14342 — closing one of a user's realtime connections deleted their presence while another connection was still open.

Solution

Track presence with a connections counter on the one-per-user presenceLogs row (unique userId), counting the live sockets holding the user:

  • A socket's first presence message creates the row (connections = 1) or increments it; a later message from the same socket just refreshes its status.
  • On close the socket decrements the row; at 0 the row is deleted and the offline event fires — so closing one connection never removes a presence another still holds (🐛 Bug Report: Closing one realtime connection deletes the user's presence while another connection is still open #14342).
  • Every decrement is guarded by the row's presenceId, so a close can't touch a row already deleted and recreated.
  • The hold lives in connections[$id]['presence'] and is released on a clean close.

State::upsertForUserWithConnections and releaseConnectionForUser own the counter transitions — transactional, serialized against the decrement-to-zero delete — and triggerPresenceDeleted emits the shared offline side effects.

Alternatives Discovered

  • Per-worker counter (earlier revision of this PR): connections counted workers, with a per-worker in-memory holder index bumping the row only on a worker's 0↔1 transition. Dropped — connections are load-balanced, so sockets-per-worker ≈ 1 and the batching saved little while adding a second in-memory structure that could outlive a connection.
  • Synchronise upsert and close (fix(realtime): synchronise presence upsert and close #14597 open, fix(realtime): synchronise presence upsert and close #14461 closed): coordinate the race through external locking/Redis. Kept the DB row as the single source of truth here instead of adding a second consistency domain.

Replace the Redis/cache holder tracking with a `connections` counter on the
one-per-user presenceLogs row = how many realtime workers currently hold the
user. The row is deleted when it reaches zero.

- Each worker keeps its holders in memory (Realtime::$presenceHolders) and
  touches the DB counter only on its 0<->1 transitions (first connection in,
  last connection out) -- heartbeats are in-memory, no per-message Redis.
- State::recordPresence increments on a worker's first hold and refreshes the
  value otherwise; State::releaseHolder decrements and deletes + returns the
  row at zero. Both run in a forUpdate transaction, so the DB row lock
  serializes a decrement-to-zero delete against another worker's increment --
  no Redis, no distributed lock, no reaper.
- onClose releases the hold inline (prompt offline event) only when the
  worker's last connection for the user closes.
- Re-auth carries the hold on same-user, releases the previous user on a
  user change. Schema gains `connections` + a V25 migration for existing rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tenki-reviewer

tenki-reviewer Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

ArnabChatterjee20k and others added 2 commits October 16, 2026 20:02
Heartbeat/value-refresh messages (not a 0->1 transition) now do a plain
sparse value update that leaves connections untouched -- no transaction,
no forUpdate. Only the worker's first hold for a user takes the transaction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Guard the usage +1 on firstWorkerHold so a heartbeat on a single-connection
user (connections already 1) does not re-count it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Security rules

No new WARNING or ERROR findings from security rules.

19 existing findings tracked in .semgrep/baseline.json
  • php.appwrite.guest-write-without-abuse-limit (12)
  • php.appwrite.secret-compare-timing (5)
  • php.appwrite.weak-secret-env-default (2)

Posted by Checks / Rules. Re-runs update this comment in place. Rule details and baseline: .semgrep/README.md.

@hansi-codes

hansi-codes Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Tier S · Ready to merge

The pull request adds a per-user presence connection counter and tracks each realtime socket’s hold so presence remains until the final hold is released. It updates the presence schema and migration, handles socket close and re-authentication cleanup, and adds coverage for counter transitions and related behavior.

Latest changes: The newest commits remove the hostname-based startup orphan sweep, its deletion helper, and the helper’s tests.

Verdict New comments Fixed Still open
✅ Approved 0 1 0
📂 Walkthrough · 11
File Change
app/config/collections/common.php Adds the per-user connection counter to the presence collection schema.
app/realtime.php Releases a socket’s presence hold on close and removes the hostname-based startup sweep.
src/Appwrite/Messaging/Adapter/Realtime.php Documents the per-connection presence-hold metadata.
src/Appwrite/Migration/Version/V25.php Migrates existing presence collections and creates the counter-enabled schema for legacy projects.
src/Appwrite/Presences/State.php Implements transactional counter transitions, guarded releases, and shared offline side effects.
src/Appwrite/Realtime/Message/Handlers/Authentication.php Preserves a socket’s hold on same-user re-authentication and releases it when the user changes.
src/Appwrite/Realtime/Message/Handlers/Presence.php Acquires or refreshes the socket hold and records the presence row sequence on the connection.
tests/e2e/Services/Presences/PresenceRealtimeClientTest.php Covers releasing the previous user’s hold after re-authentication.
tests/unit/Messaging/MessagingTest.php Covers retaining presence-hold metadata in realtime connection state.
tests/unit/Migration/Version/V25Test.php Covers adding the counter to existing collections and creating the legacy schema, including reruns.
tests/unit/Presences/StateTest.php Covers presence counter transitions, row identity, and releases.
✅ Fixed since the last review · 1
  • Do not delete shared presence rows based on the last writer's host · app/realtime.php:421

Reviewed the commits since 201a9f5 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · See the inline comments. Summary

Comment thread src/Appwrite/Realtime/Message/Handlers/Presence.php Outdated
Comment thread src/Appwrite/Presences/State.php Outdated
Comment thread src/Appwrite/Realtime/Message/Handlers/Presence.php Outdated
Comment thread src/Appwrite/Presences/State.php Outdated
- Record a connection's in-memory hold only after recordPresence commits, so a
  failed write can't leave a phantom hold that later decrements a count it
  never incremented. recordPresence retries a lost unique-userId create race
  as an increment instead of erroring.
- On a presence delete event (HTTP or worker), clear this worker's stale holds
  for that user so a later close can't decrement a count the row no longer has.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → fix-realtime-presence-connections-counter (after).

Metric Before After Change
🚀 Requests/sec 210.8 217.35 ⚪ +3.1%
⏱️ Latency P50 80.45 ms 79.33 ms ⚪ -1.4%
⏱️ Latency P95 189.28 ms 189.15 ms ⚪ -0.1%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 79.33 189.15 13,623 217.35 -0.13
Account 154.69 287.02 717 12.1 +12.14
TablesDB 76.62 146.05 7,409 120.48 -10.43
Storage 73.55 171.37 3,585 60.32 -10.12
Functions 114.09 217.87 1,912 32.92 +18.25

Top API waits (after)

API request Max wait (ms)
account.name.update 494.55
functions.delete 457.11
functions.variables.update 398.95
tablesdb.rows.create 386.05
storage.files.create 380.95

hansi-codes[bot]
hansi-codes Bot previously requested changes Oct 16, 2026

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · 1 blocking finding to address. Summary

Comment thread src/Appwrite/Realtime/Message/Handlers/Presence.php Outdated
Comment thread src/Appwrite/Messaging/Adapter/Realtime.php Outdated
- Reserve the worker-holder marker synchronously before the awaiting write,
  so two concurrent first messages on one worker can't both count as the
  first hold and double-increment. Roll the reservation back if the write
  fails.
- Tag each hold with its row id and clear only holds on the deleted row, so a
  delete event for a superseded row can't erase a hold on a recreated one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hansi-codes
hansi-codes Bot dismissed their stale review October 16, 2026 14:47

The findings that requested changes are resolved. See the summary comment for what is still open.

releaseHolder now takes the hold's presence id and no-ops when the current
row's id differs (the row was deleted and recreated since this worker's
increment), so a stale close can't under-count a row it never counted --
defense in depth with clearPresenceHolders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

Comment thread src/Appwrite/Realtime/Message/Handlers/Presence.php Outdated
Concurrent first messages on one connection share the connectionId marker.
Roll back only while it is still the reservation sentinel (true); if a
sibling message already committed and wrote the row id, leave it, so a
failed request can't erase a live hold and cause a premature release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

- V25: fold the connections attribute-add into the existing presenceLogs case
  (which also createCollection) instead of a second case that shadowed it and
  skipped collection creation on installs that never ran V24.
- Re-auth: carry the hold only when the same user's connection entry was
  restored; otherwise release it and drop the holder-index entry, so a hold
  can't outlive a stash onClose would need to release it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ArnabChatterjee20k

Copy link
Copy Markdown
Member Author

Thanks @tenki-reviewer — addressing the 4 summary findings:

  • presenceLogs migration case shadows createCollection — fixed in 211e2d7. The duplicate case 'presenceLogs': is removed; the connections attribute-add is folded into the existing case, which now createCollection()s it (for installs that never ran V24) and adds the attribute (for installs that already have the collection from V24).
  • Stale holder entry when the connection record is missing at re-auth — fixed in 211e2d7. The hold is carried over only when the same user's connection entry was restored; otherwise (user change, or the entry wasn't recreated) the holder-index entry is dropped and the worker count released, so a hold can't outlive a stash that onClose needs to release it.
  • Presence hold registered before the DB write, never rolled back — already fixed in 09269a1 / fe1269f: the reservation is rolled back on failure, and the rollback only removes its own sentinel so it can't erase a sibling's committed hold.
  • Usage trigger fires on every refresh — already fixed in 416530b: the +1 is guarded on firstWorkerHold, so it fires only on the global create, not on refreshes.

@tenki-reviewer

tenki-reviewer Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 2 high · 🟡 2 medium

💬 Inline comments (3)

  • 🟠 Close racing a heartbeat permanently leaks the presence hold — Presence.php:125
  • 🟡 Double-count users.presence when REST created the row — Presence.php:135
  • 🟡 Migration swallows connections failure, records version anyway — V25.php:425
📍 Findings outside the diff (1) — 🟠 1 high — defects on lines GitHub can't attach comments to

🟠 High — Re-subscribe drops the presence hold key · Realtime.php:202–208 · unchanged line

// src/Appwrite/Messaging/Adapter/Realtime.php
202	        $entry = [
203	            'projectId' => $projectId,
204	            'roles' => \array_values(\array_unique(\array_merge($existingRoles, $roles))),
205	            'userId' => $userId ?? ($existing['userId'] ?? ''),
206	            'channels' => \array_values(\array_unique(\array_merge($existingChannels, $channels))),
207	            'presences' => $this->connections[$identifier]['presences'] ?? []
208	        ];

Realtime::subscribe() rebuilds the connection entry from a fixed whitelist that preserves the legacy plural presences map but omits the new singular presence hold key (src/Appwrite/Messaging/Adapter/Realtime.php:207). Any client subscribe message after a presence heartbeat silently erases the hold while the stale presenceHolders[projectId][userId][connectionId] entry survives. On close, onClose finds no hold and skips cleanup, and because the holder index still lists the dead connection, workerStillHolds stays true — the presence row is never decremented or deleted until its 30-day expiry and the user appears online forever. The previous code used the plural presences key, which subscribe() does preserve, so this is a regression from the key rename.

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Orphaned docblock now sits above clearPresenceHolders (Realtime.php:267) — The new clearPresenceHolders docblock was inserted after the old removePresenceFromConnections docblock (src/Appwrite/Messaging/Adapter/Realtime.php:267-275), so that stale block ('Dedup delete presence triggers… @return int Number of connections whose presences map was updated') now documents the wrong method.

The change introduces durable presence connection counting: a new connections integer attribute on the presenceLogs collection, incremented when a connection claims a presence row and decremented when its hold is released, with the realtime adapter tracking per-connection hold keys and the V25 migration backfilling the attribute. | Files | Change | |---|---| | app/config/collections/common.php, src/Appwrite/Migration/Version/V25.php | Adds the connections attribute (default 0) to presenceLogs and a migration to create it on existing installs. | | src/Appwrite/Presences/State.php | recordPresence/releaseHolder maintain the counter in a transaction and emit create/delete events when rows appear or vanish. | | src/Appwrite/Messaging/Adapter/Realtime.php | Tracks presence hold keys per connection and clears them on close/disconnect. | | src/Appwrite/Realtime/Message/Handlers/Authentication.php, Presence.php | Release prior holds on re-auth and route presence lifecycle messages through the new hold/counter flow. | | app/realtime.php | Wires presence hold handling into the connection close path and worker initialization. | | tests/e2e/Services/Presences/PresenceRealtimeClientTest.php | E2E coverage for presence realtime behavior. | Two high-severity bookkeeping races remain: re-subscribe can drop a connection's hold key, and a close/heartbeat race can permanently leak the presence hold. The migration also swallows attribute-creation failures while still recording the version.

Reviewed commit: 211e2d7

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · Looks good to merge. Summary

Comment thread src/Appwrite/Migration/Version/V25.php Outdated
Add regressions for a legacy project missing the presenceLogs collection
(migration must create it with connections) and for an existing presenceLogs
without the column (migration must add connections).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds persistent presence connection counting to realtime (a connections counter on presenceLogs with hold tracking in the realtime adapter) plus a V25 migration, fixing hold bookkeeping across re-auth, close, and delete paths.

Key findings

  • 🟠 Re-subscribe drops the presence hold key — Realtime.php:202
  • 🟠 Close racing a heartbeat permanently leaks the presence hold — Presence.php:125
  • 🟡 Double-count users.presence when REST created the row — Presence.php:135
  • 🟡 Migration swallows connections failure, records version anyway — V25.php:425

Comment thread src/Appwrite/Realtime/Message/Handlers/Presence.php Outdated
Comment thread src/Appwrite/Realtime/Message/Handlers/Presence.php Outdated
Comment thread src/Appwrite/Migration/Version/V25.php

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

- If the socket closes during a first presence write, onClose runs before the
  hold is tagged and can't release it; the handler now detects the gone
  connection after the write, releases the row it created/incremented, and
  drops the reservation instead of resurrecting a dead hold.
- recordPresence reports whether it actually created the row; usage +1 now
  fires only on creation, not on incrementing a row an HTTP upsert already
  counted.
- V25: let the presenceLogs createCollection/connections failures propagate
  (createCollection is idempotent), so the version isn't recorded with a
  missing attribute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

Comment thread src/Appwrite/Realtime/Message/Handlers/Presence.php Outdated
If the final holder's socket closes mid-write and the cleanup deletes the
row, the handler now emits presences.*.delete (not the online upsert),
purges the list cache, and decrements usage when the deleted row was
previously counted -- instead of announcing the user online after the row
is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ArnabChatterjee20k and others added 2 commits October 19, 2026 14:18
Adds StateTest coverage for upsertForUserWithConnections (create = 1, second
socket increments, same-socket refresh does not count, a stale/gone held row
recounts or recreates) and releaseConnectionForUser (decrement, delete at zero,
presenceId-guard no-op).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses three review findings in the per-socket presence counter:

- Same-id recreation: an HTTP delete + recreate with the same explicit presence
  id kept the id, so a stale socket's close could decrement and delete the
  replacement row another socket still held. The hold is now keyed on the row's
  internal `$sequence`, which a delete-and-recreate always changes, so a stale
  hold no longer matches the live row (upsert refresh check + release guard).
- Lost create race left `created` true: reset `created`/`counted` at the start
  of each transaction attempt, so a retry that only increments no longer reports
  an online transition and over-counts usage.
- Offline event vs user lookup: triggerPresenceDeleted now tolerates a transient
  user lookup failure and emits the delete event with an empty user, as the old
  close cleanup did, so subscribers are not left with stale online state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

ArnabChatterjee20k and others added 4 commits October 19, 2026 15:36
CI failures on the per-socket presence change:

- V25 presenceLogs case: now that `connections` is in the collection config,
  createCollection() creates the row with it, so the following attribute-add
  raised Duplicate and failed the migration (and every V25Test that runs
  execute()). Catch the benign Duplicate; other failures still propagate.
- PHPStan: drop a redundant is_string() on Document::getId() (always string),
  and assert the release decrement/delete via DB state instead of the nullable
  return, which tripped an impossible-type check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…piry

A silently dead realtime client (network loss, or a peer that vanished without a
FIN) never ran onClose, so its presence lingered until the 30-day expiry
(#14343). Configure Swoole's heartbeat so a socket idle past the
idle time is closed and runs the normal onClose/release path; clients ping every
~20s, well within the window, so live connections are untouched. Shorten the
realtime presence expiry from 30 days to 1 day as the backstop for a hard crash
that fires no onClose at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ArnabChatterjee20k and others added 3 commits October 19, 2026 16:03
A hard crash fires no onClose, so a dead instance's presence rows linger until
expiry. On a clean boot every presenceLogs row tagged with this hostname is such
an orphan (new connections are only accepted after onStart), so delete them
across the console and every project, purging the list cache where rows were
removed. CE only -- a Cloud host is shared across tenants on sharded databases,
where a per-host sweep does not apply (#14343).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Realtime presence is upserted once per connection and never refreshed (a per-ping
refresh is a DB write per connection, which doesn't scale). So a short expiry
deletes live long-lived connections, since the row can't tell a held-but-stale
presence from an orphan. #14343's real cases are handled without the expiry --
the heartbeat closes silently-dead sockets and boot reconciliation clears
same-host crash orphans -- so the expiry stays a long, harmless far backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ffline

Addresses two review findings on the hostname sweep:

- Race: the detached go() runs while the server accepts connections, so a fresh
  presence upserted on this hostname mid-sweep could be deleted as an orphan.
  Capture a `$since` at boot and only sweep rows created before it; anything a
  live connection writes during the sweep is created after `$since` and left
  alone.
- Side effects: the raw bulk delete bypassed the offline path, so usage was not
  decremented and no delete event reached other workers. Each orphan is now
  deleted individually and run through triggerPresenceDeleted (usage -1 + the
  presences.[presenceId].delete event), resiliently per row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…heartbeat

fix(realtime): detect dead presence sockets via heartbeat + boot reconciliation
hansi-codes[bot]
hansi-codes Bot previously requested changes Oct 21, 2026

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · 1 blocking finding to address. Summary

Comment thread app/realtime.php Outdated
The CE orphan sweep runs asynchronously while new connections are already
accepted. A reconnect increments the same per-user row in place, which keeps
its $createdAt, so the row still matched lessThan('$createdAt', $since) and was
deleted despite the fresh live hold.

Select candidates by $updatedAt (the field a reuse actually moves) and delete
through State::deleteOrphan, which re-reads under the same forUpdate row lock
the increment takes and bails when $updatedAt >= $since. Whichever grabs the
lock first wins: a mid-sweep reconnect advances $updatedAt and is skipped; a
sweep delete lets the reconnect create a fresh row. A live hold is never lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · See the inline comments. Summary

Comment thread src/Appwrite/Presences/State.php Outdated
Add StateTest coverage for deleteOrphan: a row not touched since boot is
deleted, a row updated after $since (reconnected) is preserved, and a missing
row is a no-op.

The test caught that the guard compared $updatedAt to $since as raw strings.
$updatedAt reads back in ISO form (2026-...T...+00:00) while $since is DB
format (space-separated), so 'T' > ' ' made the comparison always true and
deleteOrphan never deleted anything. Compare them as \DateTime instants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ArnabChatterjee20k

Copy link
Copy Markdown
Member Author

@hansi-codes re review

@hansi-codes

hansi-codes Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor
  • [P1] Don’t delete a shared presence row based only on its last writer’s host. The boot sweep selects rows by hostname and age, then deleteOrphan() checks only whether $updatedAt changed since boot; it never checks whether other sockets still hold the row. A socket on host B can keep a hold on a row last written by host A without refreshing it. When A restarts, the sweep can delete that row and emit an offline event while B is still connected. The timestamp guard only protects writes during the sweep, not existing holds. See the candidate query and orphan deletion guard.

@ArnabChatterjee20k

Copy link
Copy Markdown
Member Author

@hansi-codes

[P1] Don’t delete a shared presence row based only on its last writer’s host. The boot sweep selects rows by hostname and age, then deleteOrphan() checks only whether $updatedAt changed since boot; it never checks whether other sockets still hold the row. A socket on host B can keep a hold on a row last written by host A without refreshing it. When A restarts, the sweep can delete that row and emit an offline event while B is still connected. The timestamp guard only protects writes during the sweep, not existing holds. See the candidate query and orphan deletion guard.

This is already fixed

@hansi-codes

hansi-codes Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Tier S · Looks good to merge. Summary

@ArnabChatterjee20k
ArnabChatterjee20k merged commit ba6e159 into main Oct 21, 2026
66 checks passed
@ChiragAgg5k ChiragAgg5k mentioned this pull request Oct 21, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug Report: Closing one realtime connection deletes the user's presence while another connection is still open

2 participants