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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user