CultNet: a withdrawn hop subscription is refused with a typed error and its rows are withdrawn
Reconcile's withdrawal sent an untyped error with no subscription id and left the peer holding the rows the subscription had delivered. It now sends removals for those rows, then a variant_unsupported error whose details carry field "subscriptionId" and the id (the existing details slots; no new wire key). The subscribe and per-change paths use the same code. The error schema and the Rust CultNetErrorCode accept variant_unsupported, so a Rust peer decodes it instead of refusing an unknown code. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2eec436850
commit
cbd3b4f3ce
6 changed files with 33 additions and 13 deletions
|
|
@ -28,7 +28,7 @@
|
|||
"code": {
|
||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.",
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
|
|
@ -36,8 +36,8 @@
|
|||
"additionalProperties": false,
|
||||
"required": ["field", "value", "asOf", "current"],
|
||||
"properties": {
|
||||
"field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] },
|
||||
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended.", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended.", "type": ["string", "null"] },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"code": {
|
||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it.",
|
||||
"type": ["string", "null"],
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target"]
|
||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported"]
|
||||
},
|
||||
"details": {
|
||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||
|
|
@ -36,8 +36,8 @@
|
|||
"additionalProperties": false,
|
||||
"required": ["field", "value", "asOf", "current"],
|
||||
"properties": {
|
||||
"field": { "description": "selection_invalid: the selection field that failed.", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming.", "type": ["string", "null"] },
|
||||
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended.", "type": ["string", "null"] },
|
||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended.", "type": ["string", "null"] },
|
||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ impl CultNetShardDescriptor {
|
|||
}
|
||||
}
|
||||
|
||||
/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the four strings
|
||||
/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the five strings
|
||||
/// `contracts/cultnet/cultnet.error.schema.json` enumerates for `code`; a peer that receives a fifth
|
||||
/// string it does not know refuses to decode rather than guess a meaning for it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -260,6 +260,7 @@ pub enum CultNetErrorCode {
|
|||
CursorStale,
|
||||
CursorInvalid,
|
||||
ReferenceOutsideTarget,
|
||||
VariantUnsupported,
|
||||
}
|
||||
|
||||
impl CultNetErrorCode {
|
||||
|
|
@ -269,6 +270,7 @@ impl CultNetErrorCode {
|
|||
Self::CursorStale => "cursor_stale",
|
||||
Self::CursorInvalid => "cursor_invalid",
|
||||
Self::ReferenceOutsideTarget => "reference_outside_target",
|
||||
Self::VariantUnsupported => "variant_unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -278,6 +280,7 @@ impl CultNetErrorCode {
|
|||
"cursor_stale" => Ok(Self::CursorStale),
|
||||
"cursor_invalid" => Ok(Self::CursorInvalid),
|
||||
"reference_outside_target" => Ok(Self::ReferenceOutsideTarget),
|
||||
"variant_unsupported" => Ok(Self::VariantUnsupported),
|
||||
other => Err(anyhow!("ErrorMessage.Code {other:?} is not a recognized cultnet.error.v0 code")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ namespace GameCult.Networking
|
|||
{
|
||||
// Q6: a snapshot that selects a variant is refused to this subscriber as a typed error.
|
||||
Withdraw(key, sendRemovals: false, forgetRequest: true);
|
||||
peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message });
|
||||
peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message, subscriptionId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
catch
|
||||
|
|
@ -330,7 +330,7 @@ namespace GameCult.Networking
|
|||
catch (NotSupportedException refusal)
|
||||
{
|
||||
// Q6: a variant is not a record CultNet can carry; the subscriber that would have received it is told, by key.
|
||||
peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message });
|
||||
peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -358,11 +358,12 @@ namespace GameCult.Networking
|
|||
}
|
||||
catch (NotSupportedException refusal)
|
||||
{
|
||||
// Q6: the selection selects a variant, which CultNet cannot carry. Refused as the subscribe path refuses it: the
|
||||
// subscription is withdrawn, so the peer is told once, not once per store change; a change never throws out of
|
||||
// Q6: the selection selects a variant, which CultNet cannot carry. The subscription is withdrawn, so the peer is
|
||||
// told once, not once per store change; the rows it delivered are withdrawn too (removals first), so the peer
|
||||
// does not keep them as live, and the typed error names the subscription that ended. A change never throws out of
|
||||
// the change handler.
|
||||
Withdraw(key, sendRemovals: false, forgetRequest: true);
|
||||
key.Peer.SendCultNet(new CultNetErrorMessage { Error = refusal.Message });
|
||||
Withdraw(key, sendRemovals: true, forgetRequest: true);
|
||||
key.Peer.SendCultNet(CultNetErrorMessage.ForVariantUnsupported(refusal.Message, key.Id));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -546,6 +546,18 @@ namespace GameCult.Networking
|
|||
: null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the wire error for a selection that selects a document variant, which CultNet does not carry yet: code
|
||||
/// <c>variant_unsupported</c>, details <c>{ field: "subscriptionId", value }</c> when a subscription was ended by it.
|
||||
/// </summary>
|
||||
public static CultNetErrorMessage ForVariantUnsupported(string message, string? subscriptionId = null) =>
|
||||
new CultNetErrorMessage
|
||||
{
|
||||
Error = message,
|
||||
Code = "variant_unsupported",
|
||||
Details = subscriptionId == null ? null : new CultNetErrorDetails { Field = "subscriptionId", Value = subscriptionId }
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the wire error for an out-of-target reference refusal (R-N): code
|
||||
/// <c>reference_outside_target</c>, no details.
|
||||
|
|
|
|||
|
|
@ -272,6 +272,10 @@ namespace GameCult.Networking.Tests
|
|||
await database.PutAsync(new CultRecordKey("citer-four"), Citer("citer-four", VariantKey));
|
||||
|
||||
await WaitUntilAsync(() => errors.Any(error => error.Error.Contains(VariantKey.Value)), Diagnostic);
|
||||
var refusal = errors.First(error => error.Error.Contains(VariantKey.Value));
|
||||
Assert.That(refusal.Code, Is.EqualTo("variant_unsupported"));
|
||||
Assert.That(refusal.Details?.Value, Is.EqualTo("cited"), "the peer can tell which subscription ended");
|
||||
await WaitUntilAsync(() => changes.Any(change => change.SubscriptionId == "cited" && change.ChangeKind == "removed" && change.RecordKey == BaseKey.Value), Diagnostic);
|
||||
Assert.That(changes.Where(change => change.SubscriptionId == "cited").Select(change => change.Document?.RecordKey),
|
||||
Does.Not.Contain(VariantKey.Value), "a variant never reaches a subscriber as a record");
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue