Group sends keep the outbox promise, with a real completion rule
"Undelivered - will send" on a group bubble was a lie: group text and group stickers recorded PENDING when members were unreachable and nothing queued them. The 1:1 outbox could not be stretched over it - a group send fails per member - so the entry grew the thread it restates, and the design questions got answers instead of a shrug: WHO is queued: exactly the members nothing reached. Group text reads them out of the failure map, where the no-device guard files them as "user:<id>"; the sticker loop sees per-member results directly. A member whose devices took the message is NOT queued - resending to them would duplicate it. WHERE it restates: the group thread, carried on the entry. Restating threadForUser(member) would touch a 1:1 thread that may not even exist. WHEN the bubble flips: only when the LAST queued member is reached. Flipping on the first success would mark the message delivered while other members are still owed it - "will send" would quietly stop being true for exactly the people it was still true for. Until the set drains, PENDING remains the honest label, because the queue genuinely will keep sending. The test walks the whole rule: two members away, one returns (bubble still PENDING, one entry left), the second returns (bubble DELIVERED, group thread redrawn, the prose body not replaced by the wire form). ⚠️ iOS sends to groups too (sendGroupText/sendGroupImage) and still has no group outbox - it stays on the roadmap with this as the reference implementation. Engine, client and app suites green; seven outbox tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
09a4fb90fc
commit
59fa88c2ad
@@ -257,13 +257,20 @@ class Session(
|
||||
Conversations.Sent {
|
||||
var delivered = 0
|
||||
var failed = 0
|
||||
val at = System.currentTimeMillis()
|
||||
val wire = io.flamenet.e2e.Content.sticker(pack, stickerId, groupId = group.id)
|
||||
for (member in group.memberIds) {
|
||||
if (member == userId) continue
|
||||
val one = conversations.sendSticker(member, pack, stickerId, groupId = group.id)
|
||||
delivered += one.delivered.size
|
||||
failed += one.failed.size
|
||||
// The same rule as group text: queue exactly the members nothing
|
||||
// reached, with the group thread to restate.
|
||||
if (!one.reachedSomeone) {
|
||||
enqueue(member, wire, at, thread = Vault.threadForGroup(group.id))
|
||||
}
|
||||
}
|
||||
record(Vault.threadForGroup(group.id), "", mine = true, author = userId,
|
||||
record(Vault.threadForGroup(group.id), "", mine = true, author = userId, at = at,
|
||||
sticker = Sticker(pack, stickerId),
|
||||
delivery = if (delivered > 0) Delivery.DELIVERED else Delivery.PENDING)
|
||||
return Conversations.Sent(
|
||||
@@ -343,10 +350,15 @@ class Session(
|
||||
* which transmits its argument as the plaintext verbatim — so both kinds
|
||||
* take the same path out.
|
||||
*/
|
||||
private fun enqueue(peerUserId: Long, text: String, at: Long) {
|
||||
private fun enqueue(peerUserId: Long, text: String, at: Long,
|
||||
thread: String? = null) {
|
||||
val pending = vault.outbox()
|
||||
pending.put(JSONObject()
|
||||
.put("to", peerUserId).put("text", text).put("at", at))
|
||||
.put("to", peerUserId).put("text", text).put("at", at)
|
||||
// ⚠️ Present only for group entries: the bubble to restate lives in
|
||||
// the GROUP thread, not in a 1:1 thread with this member — which
|
||||
// may not even exist.
|
||||
.also { if (thread != null) it.put("thread", thread) })
|
||||
vault.saveOutbox(pending)
|
||||
}
|
||||
|
||||
@@ -366,23 +378,50 @@ class Session(
|
||||
|
||||
val touched = mutableSetOf<String>()
|
||||
val stillWaiting = org.json.JSONArray()
|
||||
// Group entries that delivered this pass, keyed "thread|at". The bubble
|
||||
// only flips when the LAST member of its set is reached — see below.
|
||||
val groupDelivered = mutableSetOf<Pair<String, Long>>()
|
||||
|
||||
for (i in 0 until pending.length()) {
|
||||
val item = pending.optJSONObject(i) ?: continue
|
||||
val to = item.optLong("to")
|
||||
val text = item.optString("text")
|
||||
val at = item.optLong("at")
|
||||
val threadId = Vault.threadForUser(to)
|
||||
val groupThread = item.optString("thread").takeIf { it.isNotBlank() }
|
||||
|
||||
val sent = runCatching { conversations.send(to, text) }.getOrNull()
|
||||
if (sent?.reachedSomeone == true) {
|
||||
restate(threadId, at, Delivery.DELIVERED)
|
||||
touched += threadId
|
||||
if (groupThread == null) {
|
||||
restate(Vault.threadForUser(to), at, Delivery.DELIVERED)
|
||||
touched += Vault.threadForUser(to)
|
||||
} else {
|
||||
groupDelivered += groupThread to at
|
||||
}
|
||||
} else {
|
||||
stillWaiting.put(item)
|
||||
}
|
||||
}
|
||||
vault.saveOutbox(stillWaiting)
|
||||
|
||||
// 🔴 **A group bubble flips only when nobody is left waiting on it.**
|
||||
// One message fans out to many members and each gets its own entry;
|
||||
// restating on the first success would mark it delivered while other
|
||||
// members are still owed it, and "will send" would quietly stop being
|
||||
// true for them. Until the set drains, PENDING remains the honest
|
||||
// label — the queue genuinely will keep sending.
|
||||
for ((thread, at) in groupDelivered) {
|
||||
var remaining = false
|
||||
for (i in 0 until stillWaiting.length()) {
|
||||
val item = stillWaiting.optJSONObject(i) ?: continue
|
||||
if (item.optString("thread") == thread && item.optLong("at") == at) {
|
||||
remaining = true; break
|
||||
}
|
||||
}
|
||||
if (!remaining) {
|
||||
restate(thread, at, Delivery.DELIVERED)
|
||||
touched += thread
|
||||
}
|
||||
}
|
||||
return touched
|
||||
}
|
||||
|
||||
@@ -446,8 +485,20 @@ class Session(
|
||||
|
||||
fun sendGroup(group: RelayClient.Group, text: String): Conversations.Sent {
|
||||
val sent = conversations.sendToGroup(group, text, userId)
|
||||
record(Vault.threadForGroup(group.id), text, mine = true, author = userId,
|
||||
val at = System.currentTimeMillis()
|
||||
record(Vault.threadForGroup(group.id), text, mine = true, author = userId, at = at,
|
||||
delivery = if (sent.reachedSomeone) Delivery.DELIVERED else Delivery.PENDING)
|
||||
// 🔴 **The group half of the outbox promise.** Members with no
|
||||
// registered device are filed in the failure map as "user:<id>" by the
|
||||
// no-device guard; each gets an entry carrying the SAME group body and
|
||||
// the group thread to restate. A member whose devices took it is not
|
||||
// queued — resending to them would duplicate the message.
|
||||
val body = io.flamenet.e2e.Content.groupText(text, group.id)
|
||||
sent.failed.keys.filter { it.startsWith("user:") }
|
||||
.mapNotNull { it.removePrefix("user:").toLongOrNull() }
|
||||
.forEach { member ->
|
||||
enqueue(member, body, at, thread = Vault.threadForGroup(group.id))
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user