fix(appview): populate handle from PDS ingest + backfill race-safely
The AppView side of the PDS→AppView ingest path. Receives the optional 'handle' the PDS now ships, writes it onto the post row on insert (with the existing 'COALESCE(NULLIF(\"`\"), handle)' guard so an empty value never clobbers a previously-backfilled handle). The indexer's 'from_record' constructor gains an optional 'pds_handle' parameter so the same code path serves both the Jetstream ingest (where handle is absent by protocol design) and the local-PDS push (where handle is authoritative). Updates the 5 existing test call sites + adjusts the 2-step Jetstream handling flow to match. The unit/integration tests that assert the post-shape on real Bluesky docs still pass.
This commit is contained in:
@@ -214,12 +214,21 @@ impl PostRow {
|
|||||||
/// by sniffing for `$type` (`app.bsky.embed.images` / `.external` /
|
/// by sniffing for `$type` (`app.bsky.embed.images` / `.external` /
|
||||||
/// `.record`). Keeping it as raw JSON means we don't have to mirror
|
/// `.record`). Keeping it as raw JSON means we don't have to mirror
|
||||||
/// every embed variant in Rust.
|
/// every embed variant in Rust.
|
||||||
|
///
|
||||||
|
/// `pds_handle` is the optional handle forwarded by the PDS through
|
||||||
|
/// the `/internal/ingest-commit` payload. Local-PDS users have
|
||||||
|
/// `did:key:` DIDs that no PLC directory can resolve, so the PDS is
|
||||||
|
/// the only authoritative source for their handle. Pass `Some(handle)`
|
||||||
|
/// when you have it; pass `None` (e.g. Jetstream path) and the
|
||||||
|
/// `upsert_post` COALESCE guard ensures the empty value won't
|
||||||
|
/// clobber a backfilled handle from `firehose::handle_identity`.
|
||||||
pub fn from_record(
|
pub fn from_record(
|
||||||
did: &str,
|
did: &str,
|
||||||
rkey: &str,
|
rkey: &str,
|
||||||
collection: &str,
|
collection: &str,
|
||||||
cid: &str,
|
cid: &str,
|
||||||
record: &Value,
|
record: &Value,
|
||||||
|
pds_handle: Option<&str>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let text = record
|
let text = record
|
||||||
.get("text")
|
.get("text")
|
||||||
@@ -250,10 +259,14 @@ impl PostRow {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
});
|
});
|
||||||
let uri = format!("at://{did}/{collection}/{rkey}");
|
let uri = format!("at://{did}/{collection}/{rkey}");
|
||||||
|
let handle = pds_handle
|
||||||
|
.map(|h| h.trim().to_string())
|
||||||
|
.filter(|h| !h.is_empty())
|
||||||
|
.unwrap_or_default();
|
||||||
Self {
|
Self {
|
||||||
uri,
|
uri,
|
||||||
did: did.to_string(),
|
did: did.to_string(),
|
||||||
handle: String::new(),
|
handle,
|
||||||
rkey: rkey.to_string(),
|
rkey: rkey.to_string(),
|
||||||
collection: collection.to_string(),
|
collection: collection.to_string(),
|
||||||
text,
|
text,
|
||||||
@@ -575,12 +588,17 @@ pub async fn apply_commit(
|
|||||||
})?;
|
})?;
|
||||||
let cid = op.cid.clone().unwrap_or_default();
|
let cid = op.cid.clone().unwrap_or_default();
|
||||||
let record = op.record.clone().unwrap_or(Value::Null);
|
let record = op.record.clone().unwrap_or(Value::Null);
|
||||||
|
// Jetstream `commit` events don't carry the
|
||||||
|
// handle — leave it empty so the upsert
|
||||||
|
// COALESCE guard preserves the row's existing
|
||||||
|
// (or backfilled-from-identity) handle.
|
||||||
let row = PostRow::from_record(
|
let row = PostRow::from_record(
|
||||||
&ev.did,
|
&ev.did,
|
||||||
&rkey,
|
&rkey,
|
||||||
&collection,
|
&collection,
|
||||||
&cid,
|
&cid,
|
||||||
&record,
|
&record,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
upsert_post(db, &row).await?;
|
upsert_post(db, &row).await?;
|
||||||
applied = true;
|
applied = true;
|
||||||
@@ -825,7 +843,7 @@ mod tests {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
|
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
|
||||||
let embed = row.embed.expect("embed must be captured");
|
let embed = row.embed.expect("embed must be captured");
|
||||||
assert_eq!(embed["$type"], "app.bsky.embed.images");
|
assert_eq!(embed["$type"], "app.bsky.embed.images");
|
||||||
assert_eq!(embed["images"][0]["alt"], "a cat");
|
assert_eq!(embed["images"][0]["alt"], "a cat");
|
||||||
@@ -845,7 +863,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
|
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
|
||||||
let embed = row.embed.expect("embed must be captured");
|
let embed = row.embed.expect("embed must be captured");
|
||||||
assert_eq!(embed["$type"], "app.bsky.embed.external");
|
assert_eq!(embed["$type"], "app.bsky.embed.external");
|
||||||
assert_eq!(embed["external"]["uri"], "https://example.com");
|
assert_eq!(embed["external"]["uri"], "https://example.com");
|
||||||
@@ -857,7 +875,7 @@ mod tests {
|
|||||||
"text": "no embed here",
|
"text": "no embed here",
|
||||||
"createdAt": "2026-07-01T12:00:00Z"
|
"createdAt": "2026-07-01T12:00:00Z"
|
||||||
});
|
});
|
||||||
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
|
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
|
||||||
assert!(row.embed.is_none());
|
assert!(row.embed.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -871,7 +889,7 @@ mod tests {
|
|||||||
"root": {"uri": "at://did:plc:b/app.twi.post/r", "cid": "cr"}
|
"root": {"uri": "at://did:plc:b/app.twi.post/r", "cid": "cr"}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec);
|
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
|
||||||
assert_eq!(row.parent_uri.as_deref(), Some("at://did:plc:b/app.twi.post/p"));
|
assert_eq!(row.parent_uri.as_deref(), Some("at://did:plc:b/app.twi.post/p"));
|
||||||
assert_eq!(row.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r"));
|
assert_eq!(row.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r"));
|
||||||
}
|
}
|
||||||
@@ -1000,6 +1018,7 @@ mod tests {
|
|||||||
"app.twi.post",
|
"app.twi.post",
|
||||||
"cid-embed",
|
"cid-embed",
|
||||||
&record,
|
&record,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
upsert_post(&db, &row).await.unwrap();
|
upsert_post(&db, &row).await.unwrap();
|
||||||
|
|
||||||
@@ -1092,3 +1111,31 @@ mod tests {
|
|||||||
assert_eq!(count, 0);
|
assert_eq!(count, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Backfill the `posts.handle` column for every row belonging to
|
||||||
|
/// `did`. Used by the Jetstream `identity` handler when Jetstream
|
||||||
|
/// tells us a DID's handle has changed — every existing post row
|
||||||
|
/// needs the new value.
|
||||||
|
///
|
||||||
|
/// **Race-safety**: this only writes if the row's current handle is
|
||||||
|
/// empty OR doesn't match, so concurrent PDS pushes (which carry
|
||||||
|
/// the same handle) and concurrent identity replays don't fight.
|
||||||
|
/// The `WHERE handle IS DISTINCT FROM $1` makes the update a
|
||||||
|
/// no-op when the value is already correct, which Postgres treats
|
||||||
|
/// cheaply.
|
||||||
|
///
|
||||||
|
/// Returns the number of rows updated.
|
||||||
|
pub async fn backfill_handle(
|
||||||
|
db: &PgPool,
|
||||||
|
did: &str,
|
||||||
|
new_handle: &str,
|
||||||
|
) -> Result<u64> {
|
||||||
|
let res = sqlx::query(
|
||||||
|
"UPDATE posts SET handle = $1 WHERE did = $2 AND handle IS DISTINCT FROM $1",
|
||||||
|
)
|
||||||
|
.bind(new_handle)
|
||||||
|
.bind(did)
|
||||||
|
.execute(db)
|
||||||
|
.await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ use tracing::{info, warn};
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct IngestCommitReq {
|
pub struct IngestCommitReq {
|
||||||
pub did: String,
|
pub did: String,
|
||||||
|
/// The poster's current handle, as known by the PDS `users` table.
|
||||||
|
/// Optional in the wire payload — the AppView falls back to an
|
||||||
|
/// empty string, and the upsert COALESCE guard prevents the empty
|
||||||
|
/// value from clobbering a backfilled handle from the Jetstream
|
||||||
|
/// `identity` event path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub handle: Option<String>,
|
||||||
pub collection: String,
|
pub collection: String,
|
||||||
pub action: String,
|
pub action: String,
|
||||||
pub rkey: String,
|
pub rkey: String,
|
||||||
@@ -127,6 +134,7 @@ async fn apply(
|
|||||||
&req.collection,
|
&req.collection,
|
||||||
&cid,
|
&cid,
|
||||||
&record,
|
&record,
|
||||||
|
req.handle.as_deref(),
|
||||||
);
|
);
|
||||||
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
|
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|||||||
Reference in New Issue
Block a user