Repository navigation
Description
Summary
With GRAPH_POSTPONE_ATTRIBUTE_INDEX_CREATION=true, a graft (or graphman copy) taken while the source still has postponed_indexes_created = false produces a destination with postponed_indexes_created = true but without the postponed attr_* BTree indexes. Because that flag short-circuits create_postponed_indexes, the destination never builds them, not even once it reaches the chain head. Queries filtering on those attributes then run without an index.
Impact. This mostly hits grafted subgraphs. An indexer that picks up a grafted subgraph from scratch typically indexes the base only up to the graft block, so the base never gets near the chain head and never builds its postponed indexes; the graft then copies a base without them and marks them as created. Every such indexer ends up serving the grafted deployment without its attribute indexes, with nothing in the logs. A graft onto a base that is already synced on that indexer is not affected. graphman copy (operator tool) has the same problem.
Affected: v0.45.0 and current master (cc31bc7). Latent in v0.44.0 (postponement was hard-disabled in release builds), active since v0.45.0 re-enabled it (#6608).
Observed in production (v0.45.0, release build)
Deployment B was created with graphman copy create from deployment A while A was still syncing (far from the chain head). Once both were at the chain head:
- A: 709 indexes; B: 343 indexes. The 366 missing ones on B are exactly the BTree attribute indexes on scalar columns (~8.4 GB on A).
- id, reference (GiST), BRIN, pkey and
block_rangeindexes are present on both. - Both have
postponed_indexes_created = t.
Root cause (master cc31bc7, identical to v0.45.0)
store/postgres/src/copy.rscreate_indexes(l. 302-344) recreates on the destination only the indexes inindex_list, i.e. those that exist on the source (comment around l. 1302), plus indexes for new columns, then unconditionally callsset_postponed_indexes_created(dst)(l. 339-342).- Since store: Consolidate index creation using CreateIndex and add postponed index support #6434, a new deployment only builds its postponed indexes when it gets within
GRAPH_POSTPONE_INDEXES_CREATION_THRESHOLDblocks of the head (core/src/subgraph/runner/mod.rsl. 740-752). A source that is still syncing has none of them. store/postgres/src/deployment_store.rsl. 1790-1792:create_postponed_indexesreturns immediately when the flag is set.graphman copyand a manifestgraft:take the same path:copy_deploymentcreates the destination withgraft_base = src(subgraph_store.rsl. 847-886), and at startstart_subgraph_deploymentloads the source's indexes and copies (writable.rsl. 216-235). The production case above was agraphman copy; the reproductions below use a manifest graft, which exercises the same code.
The flag was added to the copy path in #6583 (74cf7c0a05, later moved by 84293d2b4d) so that a copy does not recreate indexes that were intentionally dropped on the source. It cannot tell "dropped on the source" from "not built yet on the source".
Reproduction (all three reproduced on master cc31bc7)
1. Store test (deterministic). Append the test below to store/test-store/tests/postgres/graft.rs and run:
GRAPH_POSTPONE_ATTRIBUTE_INDEX_CREATION=true just test-unit postgres::graft::graft_before_postponed_indexes
Output today:
dst after graft: flag=true postponed=["attr_0_5_user_added"]
src after create_postponed_indexes: ["attr_0_1_user_name", "attr_0_2_user_bin_name", "attr_0_3_user_email", "attr_0_4_user_age", "attr_0_5_user_seconds_age", "attr_0_6_user_weight", "attr_0_7_user_coffee", "attr_0_8_user_favorite_color"]
dst after create_postponed_indexes: ["attr_0_5_user_added"]
BUG: graft marked postponed indexes as created but they are missing: ["attr_0_5_user_added"]
test result: FAILED. 0 passed; 1 failed
Removing the set_postponed_indexes_created(dst) call in copy.rs makes the test pass (the destination then gets name, email, age, favorite_color, added), which confirms the flag is the cause.
Test code
// Regression test for: a graft/copy made before the source has created its
// postponed attribute indexes marks the destination
// `postponed_indexes_created = true` without the indexes, so they are never
// created.
//
// To append to store/test-store/tests/postgres/graft.rs (upstream/master
// cc31bc782f). It reuses that file's `run_test`, `create_grafted_subgraph`,
// `GRAFT_GQL`, `TEST_SUBGRAPH_ID`, `BLOCKS`, `USER`.
//
// Run (single-shard test config; the env var must be set for the whole
// test process because ENV_VARS is read once):
//
// GRAPH_POSTPONE_ATTRIBUTE_INDEX_CREATION=true \
// just test-unit postgres::graft::graft_before_postponed_indexes
//
// Expected today (v0.45.0 / master): the test FAILS on the last assertion
// (the destination never gets the `name`/`email`/... indexes).
// Expected once fixed: PASS.
/// Names of the indexes on `entity` that `CreateIndex::to_postpone()`
/// classifies as postponed (BTree attribute indexes, not on `id` alone)
async fn postponed_attr_indexes(
store: &DieselSubgraphStore,
loc: &DeploymentLocator,
entity: &str,
) -> Vec<String> {
let mut names: Vec<String> = store
.indexes_for_entity(loc, entity)
.await
.unwrap()
.into_iter()
.filter(|idx| idx.to_postpone())
.filter_map(|idx| idx.name().map(str::to_string))
.collect();
names.sort();
names
}
/// `subgraphs.deployment.postponed_indexes_created` for `hash`. Assumes the
/// deployment lives in the primary shard (true with the default test config)
async fn postponed_flag(hash: &DeploymentHash) -> bool {
use diesel_async::RunQueryDsl;
#[derive(diesel::QueryableByName)]
struct Flag {
#[diesel(sql_type = diesel::sql_types::Bool)]
postponed_indexes_created: bool,
}
let mut conn = PRIMARY_POOL.get().await.unwrap();
diesel::sql_query(
"select postponed_indexes_created from subgraphs.deployment where subgraph = $1",
)
.bind::<diesel::sql_types::Text, _>(hash.as_str())
.get_result::<Flag>(&mut conn)
.await
.unwrap()
.postponed_indexes_created
}
/// `create_postponed_indexes` runs in a background task; wait for the flag
async fn wait_for_flag(hash: &DeploymentHash) {
for _ in 0..60 {
if postponed_flag(hash).await {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
panic!("postponed indexes for {hash} were not created within 30s");
}
#[test]
fn graft_before_postponed_indexes() {
if !graph::env::ENV_VARS.postpone_attribute_index_creation {
println!("skipping: needs GRAPH_POSTPONE_ATTRIBUTE_INDEX_CREATION=true");
return;
}
run_test(|store, src| async move {
// 1. The source was created with postponement on and has not been
// near the chain head yet: no postponed index, flag false
assert!(!postponed_flag(&src.hash).await);
assert_eq!(
Vec::<String>::new(),
postponed_attr_indexes(&store, &src, USER).await
);
// 2. Graft onto the source at block 1, as `graph deploy` with a
// `graft:` section would (also covers `graphman copy`, which
// goes through the same `copy::Connection::copy_data`)
let dst_id = DeploymentHash::new("grafted_postponed").unwrap();
let dst = create_grafted_subgraph(
&dst_id,
GRAFT_GQL,
TEST_SUBGRAPH_ID.as_str(),
BLOCKS[1].clone(),
)
.await?;
// Today: the flag is set although only the index on the new column
// `added` was created (copy.rs `create_indexes`)
let dst_flag = postponed_flag(&dst.hash).await;
let dst_before = postponed_attr_indexes(&store, &dst, USER).await;
println!("dst after graft: flag={dst_flag} postponed={dst_before:?}");
// 3. The source gets near the head: the runner calls
// `create_postponed_indexes`, which creates them
store
.cheap_clone()
.writable(LOGGER.clone(), src.id, Arc::new(Vec::new()))
.await?
.create_postponed_indexes()
.await?;
wait_for_flag(&src.hash).await;
let src_after = postponed_attr_indexes(&store, &src, USER).await;
println!("src after create_postponed_indexes: {src_after:?}");
assert!(src_after.iter().any(|n| n.ends_with("_name")));
assert!(src_after.iter().any(|n| n.ends_with("_email")));
// 4. The destination gets near the head: same call
store
.cheap_clone()
.writable(LOGGER.clone(), dst.id, Arc::new(Vec::new()))
.await?
.create_postponed_indexes()
.await?;
wait_for_flag(&dst.hash).await;
// Leave the background task time to run if it were going to
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let dst_after = postponed_attr_indexes(&store, &dst, USER).await;
println!("dst after create_postponed_indexes: {dst_after:?}");
// Correct behaviour: the destination ends up with the postponed
// indexes for the columns it shares with the source. Fails today:
// `run()` in `create_postponed_indexes` returns early because the
// copy already set the flag.
assert!(
dst_after.iter().any(|n| n.ends_with("_name"))
&& dst_after.iter().any(|n| n.ends_with("_email")),
"BUG: graft marked postponed indexes as created but they are missing: {dst_after:?}"
);
Ok(())
})
}2. End-to-end (integration tests, Anvil). A base subgraph and a subgraph grafted onto it, run with GRAPH_POSTPONE_ATTRIBUTE_INDEX_CREATION=true GRAPH_POSTPONE_INDEXES_CREATION_THRESHOLD=-1000000000 (the negative threshold keeps the base "far from the head" on a short Anvil chain):
base: flag: false, attr_btree: 0
graft: flag: true, attr_btree: 1 (6 expected)
Happy to open a PR with this test case.
3. Operator steps (graphman + psql). Deploy A, then create B from A while A is still syncing (manifest graft: or graphman copy create), then compare postponed_indexes_created and the attr_* index count in pg_indexes:
- right after the graft: A
f / 0, Bt / 1; - after both reach the head: A
t / 6, B stillt / 1.
Note: on an idle chain a new block is needed before the runner reachesclose_to_chain_headagain.
Workaround (validated in production)
update subgraphs.deployment set postponed_indexes_created = false where id = <deployment id>; -- in the deployment's shardthen graphman restart <deployment>. The indexes are built in the background with CREATE INDEX CONCURRENTLY (in production: 343 → 709 indexes in about 15 minutes, no error). In the operator repro, B goes to t / 7. Caveat: this also recreates any postponed index that was intentionally dropped on that deployment.
Suggested fix
Propagate the source's flag instead of forcing true: read postponed_indexes_created(src) next to load_indexes(src) and set it on the destination only if it was set on the source. If it is not set, the destination builds its postponed indexes itself near the head (IF NOT EXISTS). This keeps the intent of #6583 (a copy of a slimmed-down, synced source stays slim). A release note, or a graphman command to find grafts/copies made on v0.45 with missing postponed indexes, would help operators repair affected deployments.
Related
#6583, #6608, #6434, #5140; #6722 (the opposite symptom in the same function: graphman copy adds indexes the source does not have).
Activity