Files
5d7877287c MUL-7340 fix(lark): share bind-session state across replicas so the QR survives the first status poll (#8376)
* fix(lark): serve bind-session status from Postgres so any backend replica can answer (MUL-7340)

The device-flow install session lived only in RegistrationService's
in-process map, so GET /lark/install/{session_id}/status could be
answered by exactly one backend process — the one that served begin. Any
other replica, or the same one after a restart, returned 404, which the
dialog renders as "安装会话已失效或丢失,请重新扫码" (install session not
found). The browser's first status poll fires ~5s after the QR appears,
which is exactly when users saw the QR die.

Persist the status projection in a new lark_install_session table and
read it there. The device_code is deliberately NOT persisted: it is a
bearer credential and only the process running the polling goroutine
needs it, so that process keeps its working state in memory and the row
carries just what the browser reads.

A row left 'pending' past expires_at — the owning process died mid-flow —
is now reported as expired from the timestamp rather than polling
forever. Both terminal writes are guarded on status = 'pending', so a
racing expiry and poll result resolve first-writer-wins in SQL instead of
under a mutex.

Workspace teardown drops the table's rows alongside channel_installation.

Co-authored-by: multica-agent <[email protected]>

* refactor(lark): move bind-session state from a new SQL table to the shared Redis store

Review feedback: the transient scan state does not need its own table.
The repo already has a home for exactly this shape — a short-lived,
cross-replica request whose status a browser polls — in the runtime
update / model-list / local-skill stores: Redis when configured, an
in-process implementation for single-instance development.

Replaces the lark_install_session table, its sqlc queries, the two
migrations and the sweep with an InstallSessionStore interface plus a
Redis and a memory implementation, and drops the workspace-teardown
entry the table needed. PostgreSQL keeps what it should own: the
committed Bot installation and the installer binding.

First-writer-wins now falls out of SetNX rather than an UPDATE guarded on
status = 'pending'. The record splits across an immutable key written by
Create and a terminal key written once, so a racing expiry and poll
result resolve without a read-modify-write.

Retention is the store's TTL, sized per session as the QR window plus
SessionTTL so the dialog can still read a terminal outcome recorded at
the last second — not a fixed constant that would drift from Lark's
expires_in.

A Redis error surfaces as an error, never as not-found: not-found is
terminal in the dialog, so a blip must not permanently kill a live scan.
Without Redis the service keeps the in-process store and the router logs
a warning, because that configuration reproduces MUL-7340 on more than
one replica and should not be chosen silently.

Co-authored-by: multica-agent <[email protected]>

* fix(lark): make the bind-session terminal write atomic and retried

Two failures found in review of the Redis store.

1. A terminal write that failed was logged and dropped. By the time
   markSuccess runs, the installation and the installer binding are
   already committed in Postgres, so discarding that write left a user
   who had successfully bound watching the dialog sit on 'pending' until
   the QR expired and then report failure. The write is now retried with
   backoff — 8 attempts over roughly a minute, enough to ride out a Redis
   failover rather than just a dropped packet — and the session is held
   until it settles. A store that reports the session gone stops the loop
   immediately, since no retry can fix that.

2. The record spanned two keys whose TTLs were set by separate commands.
   A SETNX that landed followed by a failed EXPIRE left the terminal
   status under the base key's original, much longer retention, and the
   retry could not repair it because SETNX then reported that someone
   else had won. A session that finished early lost its terminal field
   first and read as 'pending' again.

   The record is now a single hash written by Lua, so status and
   retention move together or not at all. The terminal script writes the
   status only when none is set — the same first-writer-wins guarantee —
   but runs EXPIRE unconditionally, which is what makes a retry
   self-repairing rather than a no-op. Create is scripted too, so a
   failure can no longer leave a key with no TTL behind.

Also drops a stale comment referencing the removed SQL file.

Co-authored-by: multica-agent <[email protected]>

* docs(lark): correct the retry budget and the atomicity claim

Re-review nits on #8376, both accuracy rather than behaviour.

The retry budget did not add up to the minute the comment claimed: 8
attempts is 7 waits totalling 25.4s, and the 15s cap was never reached.
The intent — ride out a Redis failover, not just a dropped packet — was
the right one, so raise it to 10 attempts (9 waits, 55.4s, cap now in
play) and state the arithmetic plus the per-attempt timeout on top. Also
say plainly that this is a bounded recovery window, not a guarantee.

The store comment implied Lua atomicity made the terminal write
all-or-nothing. Redis does not roll back a script that fails partway
through, so atomicity buys isolation from other clients, not recovery.
What actually removes the original two-key bug is one key with one
expiry, plus a retry that keeps the first status and re-runs EXPIRE
unconditionally. Comments now say that.

The test claiming a 'mid-script' failure injected before execution.
Renamed to what it covers, and added the case that was missing: the
script runs on the server, the client loses the reply, and the replay
must keep the first outcome AND re-assert retention.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-14 15:19:06 +08:00
..