Skip to content

Commit 8d4bba6

Browse files
fix(sdk): send schema-typed operands and move off a failing subscription node
Addresses the non-blocking findings of the review of #5285 at a181760: - The SDK sends clause operands in the form the schema stores them (`canonical_operands`), since the wire carries a few primitive types: a `u128` for a float field or `u8`s for a byte array no longer arrive as text and `u64`s the node refuses. - When a stream fails because of its node (missing history, an unreadable or undecodable block, Tenderdash unreachable), the SDK bans that node before reconnecting, so a sticky active set does not reconnect to it until the failure budget runs out; ended lifetimes and slow-client errors do not ban. - A document purchase makes the batch owner a recipient, as a token direct purchase already did. - Price IN candidates are bounded and distinct, like any IN clause's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent a181760 commit 8d4bba6

5 files changed

Lines changed: 260 additions & 13 deletions

File tree

‎packages/dash-platform-queries/src/subscriptions/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use dpp::prelude::Identifier;
2727
use drive::query::{ValueClause, WhereClause};
2828

2929
pub use proto::subscribe_request;
30-
pub use resolved::{FilterMatch, ResolvedFilters};
30+
pub use resolved::{canonical_operands, FilterMatch, ResolvedFilters};
3131

3232
/// Most filters one subscription may carry.
3333
pub const MAX_FILTERS: usize = 16;

‎packages/dash-platform-queries/src/subscriptions/participants.rs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,11 +201,15 @@ fn state_transition_owner(state_transition: &StateTransition) -> Identifier {
201201
state_transition.owner_id().unwrap_or_default()
202202
}
203203

204-
/// The identity a batch's inner document transition names as recipient: a transfer's new
205-
/// owner.
206-
pub(super) fn document_recipient(transition: &DocumentTransition) -> Option<Identifier> {
204+
/// The identity a batch's inner document transition makes the document's new owner: a
205+
/// transfer's recipient, or for a purchase the batch owner, who buys it.
206+
pub(super) fn document_recipient(
207+
transition: &DocumentTransition,
208+
batch_owner_id: Identifier,
209+
) -> Option<Identifier> {
207210
match transition {
208211
DocumentTransition::Transfer(transfer) => Some(transfer.recipient_owner_id()),
212+
DocumentTransition::Purchase(_) => Some(batch_owner_id),
209213
_ => None,
210214
}
211215
}

‎packages/dash-platform-queries/src/subscriptions/resolved.rs‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,56 @@ impl ResolvedFilters {
278278
}
279279
}
280280

281+
/// `filter` with every clause operand in the form the document type stores its field in, as
282+
/// it should be sent: the wire carries a few primitive types, so an operand the matcher accepts
283+
/// in another representation (a `u128` for a float field, `u8`s for a byte array) would arrive
284+
/// as something else. Filters other than typed document filters are returned unchanged.
285+
pub fn canonical_operands(
286+
filter: &StateTransitionFilter,
287+
data_contract: impl Fn(&Identifier) -> Option<Arc<DataContract>>,
288+
platform_version: &PlatformVersion,
289+
) -> Result<StateTransitionFilter, String> {
290+
let StateTransitionFilter::Documents(document_filter) = filter else {
291+
return Ok(filter.clone());
292+
};
293+
let Some(document_type_name) = &document_filter.document_type_name else {
294+
return Ok(filter.clone());
295+
};
296+
let contract = data_contract(&document_filter.data_contract_id).ok_or_else(|| {
297+
format!(
298+
"data contract {} not found",
299+
document_filter.data_contract_id
300+
)
301+
})?;
302+
let document_type = contract
303+
.document_type_for_name(document_type_name)
304+
.map_err(|e| e.to_string())?;
305+
let mut canonical = document_filter.clone();
306+
for action in &mut canonical.actions {
307+
for clause in action
308+
.new_document_where
309+
.iter_mut()
310+
.chain(action.original_document_where.iter_mut())
311+
{
312+
canonicalize_where_clause(document_type, clause, platform_version)
313+
.map_err(|e| e.to_string())?;
314+
}
315+
if let Some(price) = &mut action.price {
316+
price.value = match &price.value {
317+
Value::Array(values) => Value::Array(
318+
values
319+
.iter()
320+
.map(|value| value.to_integer::<u64>().map(Value::U64))
321+
.collect::<Result<_, _>>()
322+
.map_err(|e| e.to_string())?,
323+
),
324+
value => Value::U64(value.to_integer::<u64>().map_err(|e| e.to_string())?),
325+
};
326+
}
327+
}
328+
Ok(StateTransitionFilter::Documents(canonical))
329+
}
330+
281331
impl ResolvedFilter {
282332
/// Whether one inner transition of a batch owned by `batch_owner_id` matches.
283333
fn matches_batched(
@@ -295,7 +345,8 @@ impl ResolvedFilter {
295345
BatchedTransitionRef::Document(transition),
296346
) => {
297347
role.admits(Role::Recipient)
298-
&& document_recipient(transition).is_some_and(|id| identity_ids.contains(&id))
348+
&& document_recipient(transition, batch_owner_id)
349+
.is_some_and(|id| identity_ids.contains(&id))
299350
}
300351
(
301352
ResolvedFilter::Identities { identity_ids, role },
@@ -565,6 +616,21 @@ fn action_clauses(
565616
if kind != DocumentAction::UpdatePrice && action.price.is_some() {
566617
return Err("a price clause applies only to update price".to_string());
567618
}
619+
// Price IN candidates are bounded and distinct, like any IN clause's.
620+
if let Some(price) = action
621+
.price
622+
.as_ref()
623+
.filter(|price| price.operator == WhereOperator::In)
624+
{
625+
let candidates = WhereClause {
626+
field: "$price".to_string(),
627+
operator: WhereOperator::In,
628+
value: price.value.clone(),
629+
};
630+
if let Some(error) = candidates.in_values().errors.first() {
631+
return Err(error.to_string());
632+
}
633+
}
568634

569635
let document_type = contract
570636
.document_type_for_name(document_type_name)

‎packages/dash-platform-queries/src/subscriptions/tests.rs‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,3 +734,118 @@ fn should_match_a_shielded_token_unshield_by_token_and_recipient() {
734734
.unwrap();
735735
assert!(matches(&recipient, &unshield).is_some());
736736
}
737+
738+
#[test]
739+
fn should_match_a_document_buyer_as_recipient() {
740+
use dpp::state_transition::batch_transition::batched_transition::document_purchase_transition::v0::DocumentPurchaseTransitionV0;
741+
use dpp::state_transition::batch_transition::batched_transition::document_purchase_transition::DocumentPurchaseTransition;
742+
let contract = contract();
743+
let buyer = id(6);
744+
let purchase = BatchedTransition::Document(DocumentTransition::Purchase(
745+
DocumentPurchaseTransition::V0(DocumentPurchaseTransitionV0 {
746+
base: document_base(&contract, "niceDocument", 10),
747+
revision: 2,
748+
price: 100,
749+
}),
750+
));
751+
let filters = resolve(
752+
vec![StateTransitionFilter::Identities {
753+
identity_ids: vec![buyer],
754+
role: Role::Recipient,
755+
}],
756+
&contract,
757+
)
758+
.unwrap();
759+
assert_eq!(
760+
matches(
761+
&filters,
762+
&batch(buyer, vec![create_document(&contract, 11, "x"), purchase])
763+
),
764+
Some(FilterMatch {
765+
matched_filters: vec![0],
766+
matched_batch_positions: vec![1],
767+
})
768+
);
769+
}
770+
771+
#[test]
772+
fn should_bound_price_in_candidates() {
773+
use drive::query::ValueClause;
774+
let contract = contract();
775+
let price_in = |values: Vec<Value>| {
776+
StateTransitionFilter::Documents(
777+
DocumentFilter::new(contract.id())
778+
.with_document_type("niceDocument")
779+
.with_action(
780+
DocumentActionMatch::new(DocumentAction::UpdatePrice).with_price(ValueClause {
781+
operator: WhereOperator::In,
782+
value: Value::Array(values),
783+
}),
784+
),
785+
)
786+
};
787+
let too_many = price_in((0..20_000u64).map(|n| Value::U64(n % 3)).collect());
788+
let duplicates = price_in(vec![Value::U64(1), Value::U64(1)]);
789+
for filter in [too_many, duplicates] {
790+
assert!(resolve(vec![filter.clone()], &contract).is_err());
791+
let wire = StateTransitionFilter::from_proto(0, filter.to_proto().unwrap()).unwrap();
792+
assert!(resolve(vec![wire], &contract).is_err());
793+
}
794+
assert!(resolve(
795+
vec![price_in(vec![Value::U64(1), Value::U64(2)])],
796+
&contract
797+
)
798+
.is_ok());
799+
}
800+
801+
#[test]
802+
fn should_send_operands_as_the_schema_stores_them() {
803+
use dpp::data_contract::DataContractFactory;
804+
use dpp::platform_value::platform_value;
805+
let documents = platform_value!({
806+
"reading": {
807+
"type": "object",
808+
"properties": {
809+
"value": { "type": "number", "position": 0 },
810+
"blob": { "type": "array", "byteArray": true, "maxItems": 8, "position": 1 }
811+
},
812+
"additionalProperties": false
813+
}
814+
});
815+
let contract = Arc::new(
816+
DataContractFactory::new(PlatformVersion::latest().protocol_version)
817+
.expect("factory")
818+
.create_with_value_config(id(1), 1, documents, None, None)
819+
.expect("the contract parses")
820+
.data_contract_owned(),
821+
);
822+
let clause = |field: &str, value| drive::query::WhereClause {
823+
field: field.to_string(),
824+
operator: WhereOperator::Equal,
825+
value,
826+
};
827+
let filter = StateTransitionFilter::Documents(
828+
DocumentFilter::new(contract.id())
829+
.with_document_type("reading")
830+
.with_action(
831+
DocumentActionMatch::new(DocumentAction::Create)
832+
.with_new_document_where(clause("value", Value::U128(1 << 64)))
833+
.with_new_document_where(clause(
834+
"blob",
835+
Value::Array(vec![Value::U8(1), Value::U8(2)]),
836+
)),
837+
),
838+
);
839+
resolve(vec![filter.clone()], &contract).expect("resolves natively");
840+
// Sent as given, the operands arrive as text and u64s, which the node refuses.
841+
let raw = StateTransitionFilter::from_proto(0, filter.to_proto().unwrap()).unwrap();
842+
assert!(resolve(vec![raw], &contract).is_err());
843+
let canonical = canonical_operands(
844+
&filter,
845+
|_| Some(contract.clone()),
846+
PlatformVersion::latest(),
847+
)
848+
.expect("canonical operands");
849+
let wire = StateTransitionFilter::from_proto(0, canonical.to_proto().unwrap()).unwrap();
850+
resolve(vec![wire], &contract).expect("resolves after the wire round trip");
851+
}

‎packages/rs-sdk/src/platform/subscriptions.rs‎

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use dapi_grpc::platform::v0::subscribe_to_state_transitions_response::Version;
2828
use dapi_grpc::platform::v0::{SubscribeToStateTransitionsRequest, SubscribeToStateTransitionsResponse};
2929
use dapi_grpc::tonic::{Code, Status, Streaming};
3030
use dash_platform_queries::subscriptions::{
31-
subscribe_request, ResolvedFilters, StateTransitionFilter, MAX_FILTERS,
31+
canonical_operands, subscribe_request, ResolvedFilters, StateTransitionFilter, MAX_FILTERS,
3232
};
3333
use dpp::dashcore::hashes::{sha256, Hash};
3434
use dpp::data_contract::DataContract;
@@ -37,7 +37,7 @@ use dpp::serialization::PlatformDeserializableUntrusted;
3737
use dpp::state_transition::StateTransition;
3838
use dpp::version::PlatformVersion;
3939
use rs_dapi_client::transport::{sleep, TransportError};
40-
use rs_dapi_client::{DapiClientError, DapiRequestExecutor, IntoInner, RequestSettings};
40+
use rs_dapi_client::{Address, DapiClientError, DapiRequestExecutor, RequestSettings};
4141
use std::collections::{BTreeMap, BTreeSet};
4242
use std::sync::Arc;
4343
use std::time::Duration;
@@ -93,6 +93,8 @@ pub struct StateTransitionSubscription {
9393
stream: Option<Streaming<SubscribeToStateTransitionsResponse>>,
9494
/// Messages received on the current stream.
9595
stream_messages: u32,
96+
/// The node serving the current stream.
97+
stream_address: Option<Address>,
9698
/// Where the next stream starts; `None` until the first checkpoint of a live-only start.
9799
resume_from: Option<u64>,
98100
consecutive_failures: u32,
@@ -142,6 +144,14 @@ impl Sdk {
142144
data_contract_ids: data_contract_ids.into_iter().collect(),
143145
});
144146
}
147+
// Send operands in the form the schema stores them, so the node reads what was meant.
148+
let filters = filters
149+
.iter()
150+
.map(|filter| {
151+
canonical_operands(filter, |id| contracts.get(id).cloned(), self.version())
152+
})
153+
.collect::<Result<Vec<_>, _>>()
154+
.map_err(Error::Config)?;
145155
let resolved = ResolvedFilters::resolve(
146156
filters.clone(),
147157
|id| contracts.get(id).cloned(),
@@ -156,6 +166,7 @@ impl Sdk {
156166
resolved,
157167
stream: None,
158168
stream_messages: 0,
169+
stream_address: None,
159170
resume_from: from_block_height,
160171
consecutive_failures: 0,
161172
contracts_to_refresh: BTreeSet::new(),
@@ -201,6 +212,19 @@ impl StateTransitionSubscription {
201212
};
202213
if let Some(status) = &failure {
203214
tracing::debug!(code = ?status.code(), message = status.message(), "state transition stream broke; resuming");
215+
// Resume elsewhere when the node itself is at fault, rather than reconnecting
216+
// to it; a stream ended by its lifetime or the client says nothing of the node.
217+
if node_at_fault(status) {
218+
if let Some(address) = &self.stream_address {
219+
self.sdk.address_list().ban_with_reason(
220+
address,
221+
Some(format!(
222+
"state transition stream failed: {}",
223+
status.message()
224+
)),
225+
);
226+
}
227+
}
204228
}
205229
self.stream = None;
206230
self.consecutive_failures += 1;
@@ -231,14 +255,16 @@ impl StateTransitionSubscription {
231255
})
232256
}
233257

234-
async fn open(&self) -> Result<Streaming<SubscribeToStateTransitionsResponse>, Error> {
258+
async fn open(&mut self) -> Result<Streaming<SubscribeToStateTransitionsResponse>, Error> {
235259
let request: SubscribeToStateTransitionsRequest =
236260
subscribe_request(&self.filters, self.resume_from)?;
237-
self.sdk
238-
.execute(request, RequestSettings::default())
239-
.await
240-
.into_inner()
241-
.map_err(Error::from)
261+
match self.sdk.execute(request, RequestSettings::default()).await {
262+
Ok(response) => {
263+
self.stream_address = Some(response.address);
264+
Ok(response.inner)
265+
}
266+
Err(error) => Err(Error::from(error.inner)),
267+
}
242268
}
243269

244270
/// Open a new stream after a break, backing off after consecutive failures.
@@ -368,6 +394,20 @@ impl StateTransitionSubscription {
368394
}
369395
}
370396

397+
/// Whether a stream failure says the serving node cannot serve this subscription (it lacks
398+
/// history, cannot read or decode a block, or Tenderdash is unreachable), so another node
399+
/// should be tried; not an ended stream lifetime, a cancellation or a slow client.
400+
fn node_at_fault(status: &Status) -> bool {
401+
matches!(
402+
status.code(),
403+
Code::OutOfRange
404+
| Code::FailedPrecondition
405+
| Code::Unavailable
406+
| Code::Internal
407+
| Code::Unknown
408+
)
409+
}
410+
371411
/// Whether a stream that failed with `status` can continue on a new stream.
372412
fn resumable(status: &Status) -> bool {
373413
matches!(
@@ -429,6 +469,7 @@ mod tests {
429469
resolved,
430470
stream: None,
431471
stream_messages: 0,
472+
stream_address: None,
432473
resume_from: None,
433474
consecutive_failures: 0,
434475
contracts_to_refresh: BTreeSet::new(),
@@ -751,4 +792,25 @@ mod tests {
751792
.expect("skipped, not failed")
752793
.is_none());
753794
}
795+
796+
#[test]
797+
fn should_move_off_a_node_only_when_it_is_at_fault() {
798+
for code in [
799+
Code::OutOfRange,
800+
Code::FailedPrecondition,
801+
Code::Unavailable,
802+
Code::Internal,
803+
] {
804+
assert!(node_at_fault(&Status::new(code, "")), "{code:?}");
805+
}
806+
// An ended lifetime, a cancellation or a slow client is not the node's fault.
807+
for code in [
808+
Code::DeadlineExceeded,
809+
Code::Cancelled,
810+
Code::ResourceExhausted,
811+
Code::Aborted,
812+
] {
813+
assert!(!node_at_fault(&Status::new(code, "")), "{code:?}");
814+
}
815+
}
754816
}

0 commit comments

Comments
 (0)