fix(relay): delete the startup NIP-33 d_tag backfill (#8468)

## Summary

Delete the NIP-33 `d_tag` backfill that ran on every relay boot.

On every boot, before the listener opened, the relay ran:

```sql
SELECT DISTINCT community_id FROM events
WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL
```

No index covers `d_tag IS NULL`. The only `d_tag` index is the partial
`idx_events_parameterized`, which is `WHERE d_tag IS NOT NULL`. So this
query scanned every partition of `events`. In production it averaged
about 11s per boot, max 19s
(`buzz_startup_phase_seconds{phase="d_tag_backfill"}`), and found
nothing.

These rows shouldn't exist anymore:
- The backfill was the one-time migration for #246, the change that
added `d_tag`.
- In `crates/buzz-db/src`, every production write path sets the column
for kinds 30000–39999 through `extract_d_tag`: the main insert,
`replaceable.rs`, `channel_members.rs`, `relay_members.rs`, and
`push.rs`. A new source test,
`every_production_event_insert_names_d_tag`, now enforces that.
- The `events` inserts that omit `d_tag` are all test fixtures.

`scripts/backfill-d-tag.sql` is still there as the manual fallback for
any database that predates #246 and somehow never booted a relay since.

### What got simpler

- Removed `Db::backfill_d_tags` and its admission test.
- Removed the source-shape assertion in `observability_source.rs`.
- Removed the `d_tag_backfill` startup phase.
- `StartupStep::ALL` is now a slice, so removing a step no longer means
editing a hand-kept array length.

### Notes

- The `buzz_startup_phase_seconds{phase="d_tag_backfill"}` series stops
reporting.
- **Rebased onto main after #8467 merged.** Both remove a startup phase.
With the fixed-length `ALL` array the two merged cleanly in git into a
17-entry `[Self; 18]`, which doesn't compile. This PR makes `ALL` a
slice.
- **Before merging**, run this read-only query against production and
confirm it returns `0`: `SELECT count(*) FROM events WHERE kind BETWEEN
30000 AND 39999 AND d_tag IS NULL`. It scans every partition, so use a
read replica. If it isn't `0`, run `scripts/backfill-d-tag.sql` first.
The cutover script copies legacy `d_tag` values as-is, so only this
query proves historical data is clean.
- **Self-hosted installs** should run the same check before upgrading.

### Related issue

None found. Companion to #8467, which also targets relay startup time.

### Testing

- New `every_production_event_insert_names_d_tag`
(`crates/buzz-db/tests/observability_source.rs`): every production
`INSERT INTO events` must name `d_tag` in its column list. Dropping
`d_tag` from `push.rs` makes it fail and name the function.
- Runtime E2E against a real relay: boot has no `d_tag_backfill` phase;
kind:30023 replacement via WebSocket and HTTP keeps only the newer
event, an unrelated `d` survives, and an older replay can't win
(confirmed in SQL).
- Stacked on #8467 at `501dfd83`: a fresh two-replica boot runs all
enabled startup phases, with neither `nip43_reconcile` nor
`d_tag_backfill` present. Both replicas pass readiness and return the
same signed message over authenticated HTTP and WebSocket, and outsiders
are denied.
- Blox, at `501dfd83`: `just fmt-check clippy security-review-check
file-size-check` passes. Every `test-unit` step and `just test` pass
except the `buzz-acp` `git_bootstrap` native-git tests, which fail the
same way at base `b6aa5539` on Blox, and one `buzz-agent` catalog timing
test that fails only under full-suite load and passes when run alone.
- `e27139de` adds only a doc comment on
`every_production_event_insert_names_d_tag`, saying which SQL shapes the
text match covers. No code changed, so the results above still apply.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
This commit is contained in:
Luke Tornquist
2026-10-20 11:22:09 -04:00
committed by GitHub
parent c9e35f8ac7
commit c99872f749
5 changed files with 148 additions and 118 deletions

View File

@@ -1003,41 +1003,6 @@ async fn fleet_sweep_returns_committed_results_when_database_drops_mid_page() {
);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn d_tag_backfill_admits_active_tenant_and_skips_quiescing_tenant() {
let (db, communities) = fixture().await;
crate::test_support::reject_legacy_sql_fence_predicate(&db.pool).await;
for (index, community) in communities[..2].iter().enumerate() {
sqlx::query(
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig) \
VALUES ($1, $2, $3, to_timestamp(1), 30023, '[[\"d\",\"tag\"]]'::jsonb, '', $4)",
)
.bind(community.as_uuid())
.bind(vec![index as u8; 32])
.bind(vec![2_u8; 32])
.bind(vec![3_u8; 64])
.execute(&db.pool)
.await
.expect("seed addressable event");
}
assert_eq!(db.backfill_d_tags().await.unwrap(), 1);
let active: Option<String> =
sqlx::query_scalar("SELECT d_tag FROM events WHERE community_id = $1")
.bind(communities[0].as_uuid())
.fetch_one(&db.pool)
.await
.unwrap();
let quiescing: Option<String> =
sqlx::query_scalar("SELECT d_tag FROM events WHERE community_id = $1")
.bind(communities[1].as_uuid())
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!(active.as_deref(), Some("tag"));
assert_eq!(quiescing, None);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn match_claim_admits_active_tenant_and_skips_quiescing_tenant() {

View File

@@ -2776,53 +2776,6 @@ impl Db {
Ok(result)
}
/// Backfill `d_tag` for existing NIP-33 events (kind 30000–39999) that have `d_tag IS NULL`.
///
/// Idempotent — safe to call on every startup. No-ops when all rows are already populated.
/// Admits each community before updating its NIP-33 rows with NULL d_tag.
#[datastore_span(name = "backfill_d_tags", system = "postgresql")]
pub async fn backfill_d_tags(&self) -> Result<u64> {
let mut connection = crate::observability::acquire_writer(
&self.pool,
crate::observability::WriterOperation::Bootstrap,
)
.await?;
let communities: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT community_id FROM events \
WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL",
)
.fetch_all(&mut *connection)
.await?;
drop(connection);
let mut updated = 0;
for id in communities {
let Some(mut tx) = crate::begin_community_sweep_transaction(
&self.pool,
CommunityId::from_uuid(id),
crate::observability::WriterOperation::Bootstrap,
)
.await?
else {
continue;
};
let result = sqlx::query(
"UPDATE events \
SET d_tag = COALESCE( \
(SELECT elem->>1 FROM jsonb_array_elements(tags) AS elem \
WHERE elem->>0 = 'd' LIMIT 1), \
'' \
) \
WHERE community_id = $1 AND kind BETWEEN 30000 AND 39999 AND d_tag IS NULL",
)
.bind(id)
.execute(tx.conn())
.await?;
tx.commit().await?;
updated += result.rows_affected();
}
Ok(updated)
}
/// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey.
#[datastore_span(name = "soft_delete_discovery_events", system = "postgresql")]
pub async fn soft_delete_discovery_events(

View File

@@ -156,14 +156,6 @@ fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() {
assert!(event.contains("WriterOperation::Authorization"));
assert!(event.contains("WriterOperation::SubscriptionHistory"));
assert!(event.contains("ReaderOperation::SubscriptionHistory"));
let backfill_d_tags = event
.split_once("pub async fn backfill_d_tags")
.expect("event store must expose the startup d-tag backfill")
.1
.split_once("/// Soft-delete NIP-29 discovery events")
.expect("d-tag backfill must precede discovery deletion")
.0;
assert!(backfill_d_tags.contains("WriterOperation::Bootstrap"));
let soft_delete_discovery = event
.split_once("pub async fn soft_delete_discovery_events")
.expect("event store must expose discovery-event deletion")
@@ -1633,6 +1625,12 @@ fn masker_blanks_every_literal_form_and_keeps_code() {
/// continuations, `\n`/`\t`/`\r` escapes, and with or without a `public.`
/// schema prefix.
fn inserts_events_row(source: &str) -> bool {
!events_insert_tails(source).is_empty()
}
/// `source` lowercased, with every run of whitespace, `\` line continuations,
/// and `\n`/`\t`/`\r` escapes collapsed to one space.
fn normalized_sql(source: &str) -> String {
let mut normalized = String::with_capacity(source.len());
let mut chars = source.chars().peekable();
while let Some(ch) = chars.next() {
@@ -1647,16 +1645,26 @@ fn inserts_events_row(source: &str) -> bool {
normalized.push(ch.to_ascii_lowercase());
}
}
["insert into events", "insert into public.events"]
.iter()
.any(|marker| {
normalized.match_indices(marker).any(|(index, marker)| {
normalized[index + marker.len()..]
.chars()
.next()
.is_none_or(|next| !(next.is_ascii_alphanumeric() || next == '_'))
})
})
normalized
}
/// The normalized SQL that follows each `INSERT INTO events` in `source`.
fn events_insert_tails(source: &str) -> Vec<String> {
let normalized = normalized_sql(source);
let mut tails = Vec::new();
for marker in ["insert into events", "insert into public.events"] {
for (index, marker) in normalized.match_indices(marker) {
let tail = &normalized[index + marker.len()..];
if tail
.chars()
.next()
.is_none_or(|next| !(next.is_ascii_alphanumeric() || next == '_'))
{
tails.push(tail.to_owned());
}
}
}
tails
}
/// An events insert must run both follow-ups: the push enqueue and the
@@ -2062,3 +2070,124 @@ fn every_production_event_insert_runs_the_roster_guard() {
"expected at least nine event writers, found {checked_writers}"
);
}
// The rolled-back replica-floor probe writes a kind 9 row, which has no `d`.
const EVENT_INSERT_D_TAG_EXCEPTIONS: [&str; 1] = ["pub async fn verify_floor_guard_behavior("];
/// Whether an `INSERT INTO events` tail names `d_tag` in an explicit column
/// list. An insert without a column list does not count.
fn events_insert_names_d_tag(tail: &str) -> bool {
tail.trim_start()
.strip_prefix('(')
.and_then(|columns| columns.split_once(')'))
.is_some_and(|(columns, _)| columns.split(',').any(|column| column.trim() == "d_tag"))
}
fn event_insert_d_tag_violations(production_source: &str) -> Vec<String> {
function_slices(production_source)
.into_iter()
.filter(|function_source| {
let header = function_header(function_source);
!EVENT_INSERT_D_TAG_EXCEPTIONS
.iter()
.any(|exception| header.starts_with(exception))
&& !events_insert_tails(function_source)
.iter()
.all(|tail| events_insert_names_d_tag(tail))
})
.map(|function_source| function_header(function_source).to_owned())
.collect()
}
#[test]
fn event_insert_d_tag_policy_rejects_writers_without_the_column() {
let source = r##"
pub(crate) async fn writer_without_d_tag(tx: &mut AdmittedTx) {
sqlx::query("INSERT INTO events (community_id, id, kind) VALUES ($1, $2, $3)");
}
pub(crate) async fn writer_with_d_tag(tx: &mut AdmittedTx) {
sqlx::query("INSERT INTO events \
(community_id, id, kind, d_tag) VALUES ($1, $2, $3, $4)");
}
pub(crate) async fn writer_with_compact_columns(tx: &mut AdmittedTx) {
sqlx::query("insert into public.events (community_id,id,kind,d_tag) VALUES ($1,$2,$3,$4)");
}
pub(crate) async fn writer_with_d_tag_only_in_values(tx: &mut AdmittedTx) {
sqlx::query("INSERT INTO events (community_id, id, kind) SELECT community_id, id, d_tag FROM x");
}
pub(crate) async fn writer_with_lookalike_column(tx: &mut AdmittedTx) {
sqlx::query("INSERT INTO events (community_id, id, kind, d_tag_hash) VALUES ($1, $2, $3, $4)");
}
pub(crate) async fn writer_without_column_list(tx: &mut AdmittedTx) {
sqlx::query("INSERT INTO events SELECT * FROM staged_events");
}
pub(crate) async fn second_insert_without_d_tag(tx: &mut AdmittedTx) {
sqlx::query("INSERT INTO events (community_id, id, d_tag) VALUES ($1, $2, $3)");
sqlx::query("INSERT INTO events (community_id, id) VALUES ($1, $2)");
}
pub(crate) async fn mention_writer(tx: &mut AdmittedTx) {
sqlx::query("INSERT INTO event_mentions (community_id, event_id) VALUES ($1, $2)");
}
pub async fn verify_floor_guard_behavior(pool: &PgPool) {
sqlx::query("INSERT INTO events (community_id, id, kind) VALUES ($1, $2, 9)");
}
#[cfg(test)]
mod tests {
fn fixture() {
sqlx::query("INSERT INTO events (community_id, id) VALUES ($1, $2)");
}
}
"##;
assert_eq!(
event_insert_d_tag_violations(&strip_cfg_test_items(source)),
[
"pub(crate) async fn writer_without_d_tag(tx: &mut AdmittedTx) {",
"pub(crate) async fn writer_with_d_tag_only_in_values(tx: &mut AdmittedTx) {",
"pub(crate) async fn writer_with_lookalike_column(tx: &mut AdmittedTx) {",
"pub(crate) async fn writer_without_column_list(tx: &mut AdmittedTx) {",
"pub(crate) async fn second_insert_without_d_tag(tx: &mut AdmittedTx) {",
],
"every production events insert must name `d_tag` in its column list"
);
}
/// Addressable events (kinds 30000-39999) replace by `d_tag`, and the only
/// index that serves that lookup is partial on `d_tag IS NOT NULL`. A writer
/// that leaves the column out stores NULL, so its rows never replace. Nothing
/// repairs them after the fact, so every production writer must name the column.
///
/// This is a text match, not a proof. It checks the column list of literal
/// `INSERT INTO [public.]events (...)` SQL inside function bodies under each
/// crate's `src/`. It does not see `ONLY`, quoted or `format!`-built table
/// names, a comment between the keywords, or SQL held in a constant outside a
/// function, and it does not check that the bound value is non-NULL. Exempt
/// probes are skipped by function header, so every insert inside them is too.
#[test]
fn every_production_event_insert_names_d_tag() {
let mut checked_writers = 0;
let mut violations = Vec::new();
for path in production_event_writer_source_files() {
let source = std::fs::read_to_string(&path).expect("read source file");
let production = strip_cfg_test_items(&source);
checked_writers += function_slices(&production)
.into_iter()
.filter(|function| inserts_events_row(function))
.count();
violations.extend(
event_insert_d_tag_violations(&production)
.into_iter()
.map(|header| format!("{}: {header}", path.display())),
);
}
assert!(
violations.is_empty(),
"production `INSERT INTO events` writers must name `d_tag` in their column list: \
{violations:?}"
);
// The same writer set as the roster-guard scan. A lower count means the
// scan stopped seeing a writer, not that one was removed safely.
assert!(
checked_writers >= 9,
"expected at least nine event writers, found {checked_writers}"
);
}

View File

@@ -704,19 +704,6 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> {
}
step.finish();
// NIP-33: backfill d_tag for any existing parameterized replaceable events
// that predate the column addition. Idempotent — no-ops when fully populated.
let mut step = StepTimer::start(StartupStep::DTagBackfill);
match db.backfill_d_tags().await {
Ok(0) => {}
Ok(n) => info!("Backfilled d_tag for {n} NIP-33 events"),
Err(e) => {
step.degrade();
error!("Failed to backfill d_tags: {e}");
}
}
step.finish();
let (audit, audit_metrics_pool) = if config.audit_enabled {
let step = StepTimer::start(StartupStep::AuditConnect);
let audit_pool = connect_audit_pool(&db_config)

View File

@@ -51,8 +51,6 @@ pub enum StartupStep {
ReplicaFenceProbe,
/// Ensure the deployment community, backfill the allowlist, and bootstrap the owner.
MembershipBootstrap,
/// Backfill NIP-33 `d_tag` values.
DTagBackfill,
/// Connect the audit database pool (only when audit is enabled).
AuditConnect,
/// Connect Redis commands and pub/sub.
@@ -79,14 +77,13 @@ pub enum StartupStep {
impl StartupStep {
/// The complete `phase` label vocabulary.
pub const ALL: [Self; 18] = [
pub const ALL: &[Self] = &[
Self::DbConnect,
Self::DbMigrate,
Self::PartitionEnsure,
Self::DeletionFenceVerify,
Self::ReplicaFenceProbe,
Self::MembershipBootstrap,
Self::DTagBackfill,
Self::AuditConnect,
Self::RedisConnect,
Self::SearchConnect,
@@ -109,7 +106,6 @@ impl StartupStep {
Self::DeletionFenceVerify => "deletion_fence_verify",
Self::ReplicaFenceProbe => "replica_fence_probe",
Self::MembershipBootstrap => "membership_bootstrap",
Self::DTagBackfill => "d_tag_backfill",
Self::AuditConnect => "audit_connect",
Self::RedisConnect => "redis_connect",
Self::SearchConnect => "search_connect",