Skip to content

Commit 079d80d

Browse files
committed
feat(graphql): migrate dynamic fields to backward diff
Switch DynamicField::paginate from forward diff (objects_snapshot + objects_history) to backward diff (checkpointed_objects + objects_backward_history). The version-pinned path uses the new backward_view::version_pinned query; the no-version-pin path falls through to the existing consistent-view query. Read-side: - New backward_view::version_pinned query mirrors consistent::query in shape (Source A from checkpointed_objects + Source B from objects_backward_history merged via DISTINCT ON), but pivots on a version-axis cutoff rather than the cp-axis. For each candidate the target version is the largest objects_version.object_version that is <= parent_version. The state row at (object_id, target_version) is read from checkpointed_objects (current state) or objects_backward_history (prior state). Tombstone target versions exist in objects_version but have no corresponding state row in either table, so the candidate is correctly excluded. - Source A excludes rows whose (object_id, object_version) also appears in objects_backward_history, suppressing duplicates during the brief race window where backward_history has been written but checkpointed_objects has not yet been updated. - Behaviour matches the original forward-diff "earliest / produced-at" semantics: any DF with lamport <= parent_version is in scope, and the latest pre-cutoff state of each candidate is returned. Tests: - dof_add_reclaim_transfer_reclaim_add.move (delete-recreate of a Field-object with the same derived id) passes without any new schema column: the version-axis approach disambiguates the intra-checkpoint deletion gap via objects_version's tombstone versions. - dynamic_fields.move: two queries renamed away from the snapshot-lag "outside consistent range" naming (those semantics don't apply under backward-diff retention) and re-recorded.
1 parent 5cd817b commit 079d80d

5 files changed

Lines changed: 179 additions & 71 deletions

File tree

crates/iota-graphql-e2e-tests/tests/consistency/dynamic_fields/dynamic_fields.move

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,12 +485,12 @@ fragment DynamicFieldsSelect on DynamicFieldConnection {
485485
sequenceNumber
486486
}
487487
}
488-
parent_version_4_outside_consistent_range: object(address: "@{obj_2_1}", version: 4) {
488+
parent_version_4_has_df_and_dof: object(address: "@{obj_2_1}", version: 4) {
489489
dynamicFields {
490490
...DynamicFieldsSelect
491491
}
492492
}
493-
parent_version_4_paginated_outside_consistent_range: object(address: "@{obj_2_1}", version: 4) {
493+
parent_version_4_paginated_after_cursor_0: object(address: "@{obj_2_1}", version: 4) {
494494
dynamicFields(after: "@{cursor_0}") {
495495
...DynamicFieldsSelect
496496
}

crates/iota-graphql-e2e-tests/tests/consistency/dynamic_fields/dynamic_fields.snap

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
source: external-crates/move/crates/move-transactional-test-runner/src/framework.rs
3+
assertion_line: 818
34
---
45
processed 37 tasks
56

@@ -809,9 +810,51 @@ Response: {
809810
"sequenceNumber": 8
810811
}
811812
},
812-
"parent_version_4_outside_consistent_range": {
813+
"parent_version_4_has_df_and_dof": {
813814
"dynamicFields": {
814815
"edges": [
816+
{
817+
"cursor": "IBzeTue1XsgMDY1gohd7II/ts2sOHWC7tan+wRzLJfZ3CAAAAAAAAAA=",
818+
"node": {
819+
"name": {
820+
"bcs": "A2RmMw==",
821+
"type": {
822+
"repr": "0x0000000000000000000000000000000000000000000000000000000000000001::string::String"
823+
}
824+
},
825+
"value": {
826+
"json": "df3"
827+
}
828+
}
829+
},
830+
{
831+
"cursor": "IHFX3O1yuKR/xMsPcBaHHwSN9iw6Ec3lhBUKIFf1S+74CAAAAAAAAAA=",
832+
"node": {
833+
"name": {
834+
"bcs": "A2RmMg==",
835+
"type": {
836+
"repr": "0x0000000000000000000000000000000000000000000000000000000000000001::string::String"
837+
}
838+
},
839+
"value": {
840+
"json": "df2"
841+
}
842+
}
843+
},
844+
{
845+
"cursor": "INzmC22cxVEPsRD/sADbb+3OgasMgkaiYy9DEiP5M2dlCAAAAAAAAAA=",
846+
"node": {
847+
"name": {
848+
"bcs": "A2RmMQ==",
849+
"type": {
850+
"repr": "0x0000000000000000000000000000000000000000000000000000000000000001::string::String"
851+
}
852+
},
853+
"value": {
854+
"json": "df1"
855+
}
856+
}
857+
},
815858
{
816859
"cursor": "IO53M+MvTXpggmw3a4XxAozDxT5+PDBBgqapE3N1EXl7CAAAAAAAAAA=",
817860
"node": {
@@ -834,7 +877,11 @@ Response: {
834877
]
835878
}
836879
},
837-
"parent_version_4_paginated_outside_consistent_range": null,
880+
"parent_version_4_paginated_after_cursor_0": {
881+
"dynamicFields": {
882+
"edges": []
883+
}
884+
},
838885
"parent_version_6_no_df_1_2_3": {
839886
"dynamicFields": {
840887
"edges": [
@@ -907,25 +954,7 @@ Response: {
907954
"edges": []
908955
}
909956
}
910-
},
911-
"errors": [
912-
{
913-
"message": "Requested data is outside the available range",
914-
"locations": [
915-
{
916-
"line": 42,
917-
"column": 5
918-
}
919-
],
920-
"path": [
921-
"parent_version_4_paginated_outside_consistent_range",
922-
"dynamicFields"
923-
],
924-
"extensions": {
925-
"code": "BAD_USER_INPUT"
926-
}
927-
}
928-
]
957+
}
929958
}
930959

931960
task 36, lines 510-541:

crates/iota-graphql-rpc/src/backward_view/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
pub(crate) mod consistent;
88
pub(crate) mod historical;
9+
pub(crate) mod version_pinned;
910

1011
use iota_indexer::{
1112
models::objects::BackwardHistoryObjectStatus, types::ObjectStatus as NativeObjectStatus,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2026 IOTA Stiftung
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Version-pinned consistent view: the state of each candidate object at the
5+
//! moment its parent reached the requested `parent_version`.
6+
//!
7+
//! For each candidate, the target version is the largest version in
8+
//! `objects_version` whose value is `<= parent_version`. The state row at
9+
//! `(object_id, target_version)` is then read from `checkpointed_objects` (if
10+
//! it's the current state) or `objects_backward_history` (if it's a prior
11+
//! state). Non-Active rows (tombstones, NYC markers, synth
12+
//! WrappedOrDeleted) carry NULL `owner_id`/`df_kind`/etc., so any caller
13+
//! filter constraining those columns drops the candidate when target
14+
//! version lands on such a row.
15+
//!
16+
//! Mirrors the **earliest / produced-at** semantics of the original
17+
//! forward-diff `df.object_version <= parent_version` filter.
18+
19+
use crate::{
20+
backward_view::{CHECKPOINTED_COLUMNS, HISTORY_COLUMNS, merge_and_deduplicate},
21+
filter, query,
22+
raw_query::RawQuery,
23+
types::{
24+
cursor::Page,
25+
object::{Cursor, StoredBackwardObject},
26+
},
27+
};
28+
29+
/// Builds a version-pinned consistent view at `parent_version`.
30+
pub(crate) fn query(
31+
parent_version: u64,
32+
page: &Page<Cursor>,
33+
filter_fn: impl Fn(RawQuery) -> RawQuery,
34+
) -> RawQuery {
35+
let parent_version = parent_version as i64;
36+
merge_and_deduplicate(vec![
37+
version_pinned_checkpointed_objects(parent_version, page, &filter_fn),
38+
version_pinned_historical_objects(parent_version, page, &filter_fn),
39+
])
40+
}
41+
42+
/// Source A: rows in `checkpointed_objects` whose current `object_version`
43+
/// equals the largest `objects_version` entry `<= parent_version` for that
44+
/// `object_id`. Excludes rows that are also present in
45+
/// `objects_backward_history` at the same `(object_id, object_version)` —
46+
/// during the brief race window between backward-history and
47+
/// checkpointed-objects writes the prior state may already be in
48+
/// `objects_backward_history`, in which case Source B is authoritative.
49+
fn version_pinned_checkpointed_objects(
50+
parent_version: i64,
51+
page: &Page<Cursor>,
52+
filter_fn: &impl Fn(RawQuery) -> RawQuery,
53+
) -> RawQuery {
54+
let checkpointed_filtered = filter_fn(query!(format!(
55+
"SELECT {CHECKPOINTED_COLUMNS} FROM checkpointed_objects"
56+
)));
57+
58+
let with_target = filter!(
59+
checkpointed_filtered,
60+
format!(
61+
"object_version = (\
62+
SELECT MAX(object_version) FROM objects_version ov \
63+
WHERE ov.object_id = checkpointed_objects.object_id \
64+
AND ov.object_version <= {parent_version})"
65+
)
66+
);
67+
68+
let no_overlap = filter!(
69+
with_target,
70+
"NOT EXISTS (\
71+
SELECT 1 FROM objects_backward_history bh \
72+
WHERE bh.object_id = checkpointed_objects.object_id \
73+
AND bh.object_version = checkpointed_objects.object_version)"
74+
);
75+
76+
let source = query!("SELECT candidates.* FROM ({}) candidates", no_overlap);
77+
page.apply::<StoredBackwardObject>(source)
78+
}
79+
80+
/// Source B: rows in `objects_backward_history` whose `object_version` equals
81+
/// the largest `objects_version` entry `<= parent_version` for that
82+
/// `object_id`. This row carries the prior-state data of the object as it
83+
/// was at `parent_version`.
84+
fn version_pinned_historical_objects(
85+
parent_version: i64,
86+
page: &Page<Cursor>,
87+
filter_fn: &impl Fn(RawQuery) -> RawQuery,
88+
) -> RawQuery {
89+
let history_filtered = filter_fn(query!(format!(
90+
"SELECT {HISTORY_COLUMNS} FROM objects_backward_history"
91+
)));
92+
93+
let with_target = filter!(
94+
history_filtered,
95+
format!(
96+
"object_version = (\
97+
SELECT MAX(object_version) FROM objects_version ov \
98+
WHERE ov.object_id = objects_backward_history.object_id \
99+
AND ov.object_version <= {parent_version})"
100+
)
101+
);
102+
103+
let source = query!("SELECT candidates.* FROM ({}) candidates", with_target);
104+
page.apply::<StoredBackwardObject>(source)
105+
}

crates/iota-graphql-rpc/src/types/dynamic_field.rs

Lines changed: 21 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use async_graphql::{
66
connection::{Connection, CursorType, Edge},
77
*,
88
};
9-
use iota_indexer::{models::objects::StoredHistoryObject, types::OwnerType};
9+
use iota_indexer::types::OwnerType;
1010
use iota_types::{
1111
dynamic_field::{
1212
DynamicFieldInfo, DynamicFieldType, derive_dynamic_field_id,
@@ -16,7 +16,7 @@ use iota_types::{
1616
};
1717

1818
use crate::{
19-
consistency::{View, build_objects_query},
19+
backward_view::{consistent, version_pinned},
2020
data::{Db, QueryExecutor, package_resolver::PackageResolver},
2121
error::Error,
2222
filter,
@@ -28,7 +28,7 @@ use crate::{
2828
iota_address::IotaAddress,
2929
move_object::MoveObject,
3030
move_value::MoveValue,
31-
object::{self, Object, ObjectKind},
31+
object::{self, Object, ObjectKind, StoredBackwardObject},
3232
type_filter::ExactTypeFilter,
3333
},
3434
};
@@ -203,14 +203,24 @@ impl DynamicField {
203203

204204
let Some((prev, next, results)) = db
205205
.execute_repeatable(move |conn| {
206-
let Some(range) = AvailableRange::result(conn, checkpoint_viewed_at)? else {
206+
if !AvailableRange::is_checkpoint_in_backward_history_range(
207+
conn,
208+
checkpoint_viewed_at,
209+
)? {
207210
return Ok::<_, diesel::result::Error>(None);
208211
};
209212

210-
Ok(Some(page.paginate_raw_query::<StoredHistoryObject>(
213+
let query = match parent_version {
214+
Some(pv) => version_pinned::query(pv, &page, |q| apply_filter(q, parent)),
215+
None => {
216+
consistent::query(checkpoint_viewed_at, &page, |q| apply_filter(q, parent))
217+
}
218+
};
219+
220+
Ok(Some(page.paginate_raw_query::<StoredBackwardObject>(
211221
conn,
212222
checkpoint_viewed_at,
213-
dynamic_fields_query(parent, parent_version, range, &page),
223+
query,
214224
)?))
215225
})
216226
.await?
@@ -226,9 +236,10 @@ impl DynamicField {
226236
// To maintain consistency, the returned cursor should have the same upper-bound
227237
// as the checkpoint found on the cursor.
228238
let cursor = stored.cursor(checkpoint_viewed_at).encode_cursor();
239+
let stored_history = stored.into_stored_history(checkpoint_viewed_at);
229240

230241
let object = Object::try_from_stored_history_object(
231-
stored,
242+
stored_history,
232243
checkpoint_viewed_at,
233244
parent_version,
234245
)?;
@@ -280,51 +291,13 @@ impl TryFrom<MoveObject> for DynamicField {
280291
}
281292
}
282293

283-
/// Builds the `RawQuery` for fetching dynamic fields attached to a parent
284-
/// object. If `parent_version` is null, the latest version of each field within
285-
/// the given checkpoint range [`lhs`, `rhs`] is returned, conditioned on the
286-
/// fact that there is not a more recent version of the field.
287-
///
288-
/// If `parent_version` is provided, it is used to bound both the `candidates`
289-
/// and `newer` objects subqueries. This is because the dynamic fields of a
290-
/// parent at version v are dynamic fields owned by the parent whose versions
291-
/// are <= v. Unlike object ownership, where owned and owner objects
292-
/// can have arbitrary `object_version`s, dynamic fields on a parent cannot have
293-
/// a version greater than its parent.
294-
fn dynamic_fields_query(
295-
parent: IotaAddress,
296-
parent_version: Option<u64>,
297-
range: AvailableRange,
298-
page: &Page<object::Cursor>,
299-
) -> RawQuery {
300-
build_objects_query(
301-
View::Consistent,
302-
range,
303-
page,
304-
move |query| apply_filter(query, parent, parent_version),
305-
move |newer| {
306-
if let Some(parent_version) = parent_version {
307-
filter!(newer, format!("object_version <= {}", parent_version))
308-
} else {
309-
newer
310-
}
311-
},
312-
)
313-
}
314-
315-
fn apply_filter(query: RawQuery, parent: IotaAddress, parent_version: Option<u64>) -> RawQuery {
316-
let query = filter!(
294+
fn apply_filter(query: RawQuery, parent: IotaAddress) -> RawQuery {
295+
filter!(
317296
query,
318297
format!(
319298
"owner_id = '\\x{}'::bytea AND owner_type = {} AND df_kind IS NOT NULL",
320299
hex::encode(parent.into_vec()),
321300
OwnerType::Object as i16
322301
)
323-
);
324-
325-
if let Some(version) = parent_version {
326-
filter!(query, format!("object_version <= {}", version))
327-
} else {
328-
query
329-
}
302+
)
330303
}

0 commit comments

Comments
 (0)