fix(projects): let a membership's foreign key violation out, so the sweep retries

A project's memberships are foreign keys into both the project and the version
they name, so a chronicle page carrying a membership whose project is in a
*later* page cannot apply it yet. `ChronicleManager.applyPage` handles exactly
this: it catches the violation, counts the payload `failed`, and `sweep` re-runs
every page until failures stop falling.

Phase 3 broke that for projects by catching the violation inside the apply arm.
`applyInnerEvent` then returned normally, `applyPage` counted the payload
applied, the sweep saw nothing left to do, and the membership was dropped --
silently, permanently, on the device a chronicle exists for.

The catch was added so that `FrostSigningManager.applySignedEvent` would not
report a succeeded session as failed. That reasoning was already satisfied:
`applySignedEvent` wraps the call in its own try/catch. And no sibling arm
catches -- a translation chapter naming an absent chapter throws today -- so the
catch made projects the only kind a chronicle could not retry. It is gone.

Pages are 128 events or 64 KiB, and projects sort last in APPLY_ORDER, so a
boundary falling between a project and its memberships is the ordinary case for
any room with a chapter's worth of chunks in it.

`pages arriving out of order still converge` already modelled this -- one page
per payload, delivered backwards, comparing every row count on both devices --
and passed anyway, because `rowCounts` did not look at the project tables. It
does now, which is what makes that test the one that would have caught this.
`ProjectApplyJvmTest` asserts the throw rather than the swallow, and adds the
retry in miniature: refused while the project is missing, accepted once it is
there, same event both times.

1404 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 20:05:08 +02:00
parent aceef313ed
commit 2fb8d6921b
4 changed files with 109 additions and 51 deletions

View File

@@ -1330,13 +1330,23 @@ data class ChatMessage(
// writes "The group signed ..." when the session completes, and
// three more lines here would be a second account of it.
//
// The row may be refused: both references are foreign keys, so a
// membership naming a version this device does not hold cannot be
// written. That is the one-room scope decision doing its job
// rather than an error to propagate -- `applySignedEvent`
// swallows throwables because the signature is valid either way,
// and letting this one reach it would report a session that
// succeeded as a session that failed.
// **The foreign key violation is allowed out, and has to be.**
// Both references are keys, so a membership naming a version or
// a project this device does not hold cannot be written -- and
// `ChronicleManager.applyPage` learns that only by catching it.
// It counts the payload failed, and `sweep` runs the pages again
// until failures stop falling, which is the whole mechanism that
// delivers a membership whose project landed in a *later* page.
// Swallowing it here counts the payload as applied, the sweep
// terminates, and the membership is lost silently on the one
// device that has nothing else to fall back on.
//
// It was caught here for one commit, on the reasoning that
// `applySignedEvent` must not report a succeeded session as
// failed. That reasoning was already satisfied: it has its own
// try/catch. And no sibling arm catches -- a translation chapter
// naming an absent chapter throws today -- so catching made this
// the only kind the chronicle could not retry.
ProjectArtifactVersionEvent.KIND -> {
MantraProjectArtifactVersion.fromProjectArtifactVersionEvent(
projectArtifactVersionEvent = ProjectArtifactVersionEvent(
@@ -1349,21 +1359,12 @@ data class ChatMessage(
),
chatRoomId = groupId,
)?.let { membership ->
try {
database.mantraProjectArtifactVersionDao().upsert(
membership.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
database.mantraProjectArtifactVersionDao().upsert(
membership.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
} catch (error: Throwable) {
logger.w(
"Project ${membership.projectId.take(8)} names artifact " +
"version ${membership.artifactVersionId.take(8)}, which " +
"this device does not hold",
error
)
}
)
}
null
}
@@ -1383,22 +1384,12 @@ data class ChatMessage(
),
chatRoomId = groupId,
)?.let { membership ->
try {
database.mantraProjectTranslationArtifactVersionDao().upsert(
membership.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
database.mantraProjectTranslationArtifactVersionDao().upsert(
membership.copy(
marmotGroupEventId = marmotGroupEventId,
groupSignedEventId = groupSignedEventId,
)
} catch (error: Throwable) {
logger.w(
"Project ${membership.projectId.take(8)} names " +
"translation " +
membership.translationArtifactVersionId.take(8) +
", which this device does not hold",
error
)
}
)
}
null
}