-
Notifications
You must be signed in to change notification settings - Fork 37
fix(node): ANS-104 transport, three-outcome probe, verify endpoint (#26 split 2/4) #385
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a53a63a
f2658b4
5691bf2
f4c2340
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1123,6 +1123,29 @@ const MIGRATIONS: &[Migration] = &[ | |
| "ALTER TABLE pin_repair_sweep ADD COLUMN IF NOT EXISTS discovery_cursor_id TEXT NOT NULL DEFAULT ''", | ||
| ], | ||
| }, | ||
| Migration { | ||
| version: 27, | ||
| name: "arweave_anchors_irys_tx_id_index", | ||
| stmts: &[ | ||
| // #26 split 2/4 (P2, reviewer round 2): backs the verify | ||
| // endpoint's `SELECT ... WHERE irys_tx_id = $1` at | ||
| // mod.rs:~3896. Without it every anonymous probe | ||
| // seq-scans the table (the existing indexes on | ||
| // `(repo, new_sha)` cannot serve it), so verify becomes | ||
| // O(rows) and the v2 transport's per-probe O(1) silently | ||
| // regresses. | ||
| // | ||
| // NON-UNIQUE on purpose: `record_arweave_anchor` | ||
| // generates a fresh UUID per call and a retry of the v2 | ||
| // transport could legitimately write the same | ||
| // `irys_tx_id` twice; a UNIQUE constraint would fail on | ||
| // existing data and force a separate backfill decision. | ||
| // Promote to UNIQUE later only after auditing duplicates. | ||
| // | ||
| // NEW versioned migration (never appended to an applied block, INV-7). | ||
| "CREATE INDEX IF NOT EXISTS idx_arweave_anchors_irys_tx_id ON arweave_anchors(irys_tx_id)", | ||
| ], | ||
| }, | ||
| ]; | ||
|
|
||
| /// Max distinct source repos recorded per pinned object (F1, #173 jatmn round 8). | ||
|
|
@@ -3874,6 +3897,44 @@ impl Db { | |
| }) | ||
| .collect()) | ||
| } | ||
|
|
||
| /// Look up a single Arweave anchor by its externally-routable | ||
| /// transaction id (the `irys_tx_id` column). | ||
| /// | ||
| /// Production callers — including the public verify endpoint | ||
| /// `GET /api/v1/arweave/anchors/verify/{item_id}` — pass the | ||
| /// gateway item id (the Irys response `id` for v1 anchors, the | ||
| /// ANS-104-derived `base64url(SHA256(signature))` for v2 anchors). | ||
| /// That value lives in `irys_tx_id`; the `id` column is an | ||
| /// internal UUID generated by [`Db::record_arweave_anchor`] and | ||
| /// is NOT routable from outside the node. | ||
| /// | ||
| /// Returns `None` if no row matches. | ||
| pub async fn get_arweave_anchor_by_item_id( | ||
| &self, | ||
| item_id: &str, | ||
| ) -> Result<Option<ArweaveAnchor>> { | ||
| let row = sqlx::query( | ||
| "SELECT id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, arweave_url, node_did, anchored_at | ||
| FROM arweave_anchors WHERE irys_tx_id = $1", | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| .bind(item_id) | ||
| .fetch_optional(&self.pool) | ||
| .await?; | ||
| Ok(row.map(|r| ArweaveAnchor { | ||
| id: r.get("id"), | ||
| repo: r.get("repo"), | ||
| owner_did: r.get("owner_did"), | ||
| ref_name: r.get("ref_name"), | ||
| old_sha: r.get("old_sha"), | ||
| new_sha: r.get("new_sha"), | ||
| cid: r.get("cid"), | ||
| irys_tx_id: r.get("irys_tx_id"), | ||
| arweave_url: r.get("arweave_url"), | ||
| node_did: r.get("node_did"), | ||
| anchored_at: r.get("anchored_at"), | ||
| })) | ||
| } | ||
| } | ||
|
|
||
| // ── Row helpers ─────────────────────────────────────────────────────────────── | ||
|
|
@@ -5094,6 +5155,124 @@ mod migration_tests { | |
| assert_eq!(attempted_at_of(&db, "z6Mkfoo/failed").await, None); | ||
| assert_eq!(attempted_at_of(&db, "z6Mkfoo/done").await, None); | ||
| } | ||
|
|
||
| /// #26 split 2/4 (P2, reviewer round 2): the verify endpoint's | ||
| /// `SELECT ... WHERE irys_tx_id = $1` at mod.rs:~3896 is a | ||
| /// sequential scan unless `idx_arweave_anchors_irys_tx_id` | ||
| /// exists. Migration v27 is the only place that index is | ||
| /// created. This test pins the index's presence against a | ||
| /// future change that drops v27 from the array (a typo fix, a | ||
| /// misread of the review, an accidental revert) — without it | ||
| /// the suite stays green and the regression only shows up in | ||
| /// production EXPLAIN plans. | ||
| #[sqlx::test] | ||
| async fn migration_v27_creates_irys_tx_id_index(pool: sqlx::PgPool) { | ||
| let db = super::Db::for_testing(pool); | ||
| db.migrate().await.unwrap(); | ||
| let row = sqlx::query( | ||
| "SELECT 1 AS present FROM pg_indexes | ||
| WHERE schemaname = 'public' | ||
| AND tablename = 'arweave_anchors' | ||
| AND indexname = 'idx_arweave_anchors_irys_tx_id'", | ||
| ) | ||
| .fetch_optional(&db.pool) | ||
| .await | ||
| .unwrap(); | ||
| assert!( | ||
| row.is_some(), | ||
| "idx_arweave_anchors_irys_tx_id is missing — migration v27 \ | ||
| was not applied. The verify endpoint will seq-scan the table." | ||
| ); | ||
| } | ||
|
|
||
| /// Round-3 P2 (reviewer): the presence test above only proves | ||
| /// the index EXISTS; a future "fix" could re-add it under a | ||
| /// non-equivalent name (wrong column, partial index) and the | ||
| /// presence test would still pass. The verify lookup query | ||
| /// would then seq-scan. This test pins the index is actually | ||
| /// USED by the verify lookup: with `enable_seqscan = off`, | ||
| /// the planner has no other option and the query must succeed; | ||
| /// with the default plan, the EXPLAIN output must name | ||
| /// `idx_arweave_anchors_irys_tx_id`. A bug in the index | ||
| /// definition (wrong column, INCLUDE-only, partial predicate | ||
| /// excluding the lookup value) flips this test RED. | ||
| #[sqlx::test] | ||
| async fn migration_v27_irys_tx_id_index_is_used_by_the_lookup(pool: sqlx::PgPool) { | ||
| let db = super::Db::for_testing(pool); | ||
| db.migrate().await.unwrap(); | ||
|
|
||
| // Seed one row so the planner has data to estimate against. | ||
| // The actual value of `irys_tx_id` does not matter — the | ||
| // index lookup path is the same for any string. | ||
| sqlx::query( | ||
| r#"INSERT INTO arweave_anchors | ||
| (id, repo, owner_did, ref_name, old_sha, new_sha, cid, irys_tx_id, | ||
| arweave_url, node_did, anchored_at) | ||
| VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"#, | ||
| ) | ||
| .bind(uuid::Uuid::new_v4().to_string()) | ||
| .bind("o/r") | ||
| .bind("o") | ||
| .bind("refs/heads/main") | ||
| .bind("0".repeat(40)) | ||
| .bind("1".repeat(40)) | ||
| .bind(Option::<String>::None) | ||
| .bind("seeded-irys-tx-for-explain") | ||
| .bind("https://arweave.net/x") | ||
| .bind("did:key:zN") | ||
| .bind(chrono::Utc::now().to_rfc3339()) | ||
| .execute(&db.pool) | ||
| .await | ||
| .unwrap(); | ||
|
|
||
| // Force the planner to use the index. If the index is | ||
| // missing or does not cover `irys_tx_id`, the planner | ||
| // has no other option and the query will fall back to | ||
| // an error (seqscan disabled, no index to use). This is | ||
| // the most direct proof the index is wired correctly. | ||
| sqlx::query("SET enable_seqscan = off") | ||
| .execute(&db.pool) | ||
| .await | ||
| .unwrap(); | ||
|
|
||
| let row = | ||
| sqlx::query("SELECT id, repo, irys_tx_id FROM arweave_anchors WHERE irys_tx_id = $1") | ||
| .bind("seeded-irys-tx-for-explain") | ||
| .fetch_optional(&db.pool) | ||
| .await | ||
| .expect( | ||
| "verify lookup failed with seqscan disabled — the index either \ | ||
| does not exist, is on the wrong column, or is a partial index \ | ||
| that excludes this row. The verify endpoint will seq-scan in \ | ||
| production as a result.", | ||
| ); | ||
| assert!( | ||
| row.is_some(), | ||
| "verify lookup returned no row for a seeded value; the index \ | ||
| may be a covering index that does not return the row, or the \ | ||
| planner path is broken" | ||
| ); | ||
|
|
||
| // Reset and confirm the default EXPLAIN names the index. | ||
| sqlx::query("SET enable_seqscan = on") | ||
| .execute(&db.pool) | ||
| .await | ||
| .unwrap(); | ||
| let plan: (String,) = | ||
| sqlx::query_as("EXPLAIN SELECT id FROM arweave_anchors WHERE irys_tx_id = $1") | ||
| .bind("seeded-irys-tx-for-explain") | ||
| .fetch_one(&db.pool) | ||
| .await | ||
| .expect("EXPLAIN failed; the verify query is broken"); | ||
| let plan_lines: Vec<&str> = plan.0.lines().collect(); | ||
| let plan_text = plan_lines.join(" | "); | ||
| assert!( | ||
| plan_text.contains("idx_arweave_anchors_irys_tx_id"), | ||
| "the default EXPLAIN did not use idx_arweave_anchors_irys_tx_id. \ | ||
| Plan: {plan_text}. The index is present but the planner chose \ | ||
| another path; the verify endpoint will seq-scan in production." | ||
|
Comment on lines
+5269
to
+5273
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- scoped repository knowledge ---'
for f in /tmp/coderabbit-repo-knowledge/gitlawb-node-7dd62aa5/*/*.md; do
case "$f" in
*/db/*|*/learnings/*) printf '\n### %s\n' "$f"; head -200 "$f" ;;
esac
done
printf '%s\n' '--- target outline ---'
ast-grep outline crates/gitlawb-node/src/db/mod.rs | sed -n '1,180p'
printf '%s\n' '--- migration and test context ---'
sed -n '80,180p' crates/gitlawb-node/src/db/mod.rs
sed -n '5180,5305p' crates/gitlawb-node/src/db/mod.rs
printf '%s\n' '--- direct pool/query bindings in target ---'
rg -n -C 3 'enable_seqscan|EXPLAIN|idx_arweave_anchors_irys_tx_id|PgPool|MIGRATIONS' crates/gitlawb-node/src/db/mod.rsRepository: Gitlawb/node Length of output: 50368 🌐 Web query:
💡 Result: PostgreSQL sequential scans on small tables are expected behavior, as the query planner often determines that reading the entire table is cheaper than performing index lookups and random heap accesses [1][2][3][4]. The parameter enable_seqscan does not disable sequential scans, but rather discourages the planner from using them when other plans are possible [5][6]. It should be used as a diagnostic tool within a single session—not as a configuration setting—to test if alternative plans might perform better [7][6]. For session-level modifications, you can use SET enable_seqscan = off; to test this behavior [7][8]. When using libraries like sqlx with a PgPool, keep in mind that PgPool manages a set of persistent connections [9][10][11]. If you call SET enable_seqscan = off; on a connection checked out from the pool, that setting will persist for the remainder of that connection's life unless explicitly reset, or if the connection is closed and returned to the pool [8]. Since sqlx executors often automatically checkout and return connections [9][12], you should use local transaction-scoped settings—such as SET LOCAL enable_seqscan = off; within a transaction block—if you only want to affect a specific query [8]. Summary of Key Concepts: Small Tables: Sequential scans are typically faster for small tables because the entire table may fit in a single disk page, making index overhead wasteful [1][2][4]. Diagnostic Usage: Set enable_seqscan = off in a session to force the planner to reveal the cost of alternative plans during EXPLAIN analysis [7][6]. Do not use this as a permanent global configuration [6]. Session Management: In pooled environments like sqlx, use SET LOCAL within a transaction to ensure that changes to session parameters do not leak into subsequent uses of the same connection [8]. Citations:
Do not require the default planner to select this index. This test inserts one row and requires default Validate the index definition through 🤖 Prompt for AI Agents |
||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
|
|
@@ -8552,3 +8731,114 @@ mod cid_candidate_order_tests { | |
| ); | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod arweave_anchor_lookup_tests { | ||
| //! The production writer at `Db::record_arweave_anchor` generates | ||
| //! a fresh UUID for the internal `id` column and stores the | ||
| //! externally-routable transaction id in `irys_tx_id`. The | ||
| //! public verify endpoint looks anchors up by the | ||
| //! externally-routable id, so `get_arweave_anchor_by_item_id` | ||
| //! must filter on `irys_tx_id` — the prior `WHERE id = $1` form | ||
| //! would always 404 on real anchors (the internal UUID never | ||
| //! matches the gateway item id) and only passed the older | ||
| //! fixtures because they seeded `id = item_id`. | ||
| //! | ||
| //! The fixture deliberately uses distinct values for `id` (a | ||
| //! fresh UUID generated by `record_arweave_anchor`) and | ||
| //! `irys_tx_id` (the Irys response `id`, or the ANS-104 derived | ||
| //! id) so the test exercises the production writer path, not | ||
| //! a masked self-round-trip. | ||
|
|
||
| use crate::db::Db; | ||
| use crate::db::RecordAnchorInput; | ||
| use sqlx::PgPool; | ||
|
|
||
| async fn _db(pool: PgPool) -> Db { | ||
| let db = Db::for_testing(pool); | ||
| db.run_migrations().await.unwrap(); | ||
| db | ||
| } | ||
|
|
||
| #[sqlx::test] | ||
| async fn record_then_lookup_round_trips_via_irys_tx_id(pool: PgPool) { | ||
| let db = _db(pool).await; | ||
|
|
||
| // The production writer's signature: external item id lives | ||
| // in `irys_tx_id`, the internal UUID is generated. | ||
| let item_id = "abc-external-43-char-base58-7xGpIoHUQ8j9GhD3Y2mKzP1NsVtXwRcFe"; | ||
| let old_sha = "0".repeat(40); | ||
| let new_sha = "1".repeat(40); | ||
| let arweave_url = format!("https://arweave.net/{item_id}"); | ||
| db.record_arweave_anchor(&RecordAnchorInput { | ||
| repo: "alice/repo", | ||
| owner_did: "did:key:z6owner", | ||
| ref_name: "refs/heads/main", | ||
| old_sha: &old_sha, | ||
| new_sha: &new_sha, | ||
| cid: None, | ||
| irys_tx_id: item_id, | ||
| arweave_url: &arweave_url, | ||
| node_did: "did:key:z6node", | ||
| }) | ||
| .await | ||
| .unwrap(); | ||
|
|
||
| // The reader filters on `irys_tx_id`, not `id`. The lookup | ||
| // MUST return the row. | ||
| let row = db | ||
| .get_arweave_anchor_by_item_id(item_id) | ||
| .await | ||
| .unwrap() | ||
| .expect("the row is reachable by the externally-routable id"); | ||
| assert_eq!(row.irys_tx_id, item_id); | ||
| assert_eq!(row.repo, "alice/repo"); | ||
| assert_eq!(row.node_did, "did:key:z6node"); | ||
| // The internal `id` is a UUID — distinct from the item id. | ||
| assert_ne!(row.id, item_id); | ||
| assert_eq!(row.id.len(), 36, "id is a 36-char UUID"); | ||
| } | ||
|
|
||
| #[sqlx::test] | ||
| async fn lookup_with_internal_uuid_returns_none(pool: PgPool) { | ||
| let db = _db(pool).await; | ||
| let item_id = "real-tx-id-base58-43-chars-7xGpIoHUQ8j9GhD3Y2mKzP1N"; | ||
| let old_sha = "0".repeat(40); | ||
| let new_sha = "1".repeat(40); | ||
| let arweave_url = format!("https://arweave.net/{item_id}"); | ||
| db.record_arweave_anchor(&RecordAnchorInput { | ||
| repo: "alice/repo", | ||
| owner_did: "did:key:z6owner", | ||
| ref_name: "refs/heads/main", | ||
| old_sha: &old_sha, | ||
| new_sha: &new_sha, | ||
| cid: None, | ||
| irys_tx_id: item_id, | ||
| arweave_url: &arweave_url, | ||
| node_did: "did:key:z6node", | ||
| }) | ||
| .await | ||
| .unwrap(); | ||
|
|
||
| // The row's internal `id` is a UUID. Looking it up via | ||
| // `get_arweave_anchor_by_item_id` MUST return `None` — the | ||
| // prior `WHERE id = $1` filter would have spuriously | ||
| // returned the row, which is the production bug. | ||
| let row = db | ||
| .get_arweave_anchor_by_item_id( | ||
| &db.list_arweave_anchors(None, 10) | ||
| .await | ||
| .unwrap() | ||
| .first() | ||
| .expect("row exists") | ||
| .id, | ||
| ) | ||
| .await | ||
| .unwrap(); | ||
| assert!( | ||
| row.is_none(), | ||
| "the internal UUID is NOT a valid externally-routable item id; \ | ||
| the lookup must filter on irys_tx_id, not id" | ||
| ); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.