mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
## Summary Fix workflow deletion leaving its definition queryable, and make public deletion acceptance agree atomically with executable/definition state. - Delete the executable workflow and soft-delete its definition under the existing coordinate lock, in the same transaction as kind-5 acceptance and mention indexing. Failed deletion leaves no public accepted event; identical retries can succeed. Legacy already-stored failed requests still repair. - Choose one committed dispatch/audit owner for concurrent identical requests. Completed duplicates remain silent. Preserve owner/community isolation, newer revisions, orphan cleanup, and intentional backdated recreation. - Align Desktop confirmation and architecture documentation with recreation semantics. Add deterministic PostgreSQL, live WebSocket, and Desktop regressions. No schema migration or new retry/delivery subsystem. ## Current validation Head: `40d86ddab0853ffb292316653b22c3fd4f983f0c`. - **Exact clean head:** live relay workflow-deletion E2E **4/4 passed**. All applicable normal pre-push hooks passed: Rust unit suites, full Desktop unit suite, Desktop lint/typecheck, Tauri clippy/tests, and file-size/branch checks. No hook bypass. - **Pre-commit implementation tree, then committed at this head:** full discovered DB/relay PostgreSQL lane **351/351 passed**; built Desktop deletion Playwright smoke **3/3 passed** (success, failure preserving editor/draft, corrected confirmation copy). - **Deterministic mutation evidence:** unchanged previous relay `659b79282` fails both new regressions: concurrent identical requests produce two normal/dispatch-owner responses, and rejected deletion leaves one public event. Both controls pass. Fixed relay passes all four. - **Independent source review:** transaction/authorization/lock-order and regression/cleanup lanes clear. Reviewers did not run tests independently. ## Honest limits / merge gates The broad pre-commit DB/relay/test-client selection passed **1,215/1,216**; the untouched mesh-demo echo test returned HTTP 504 instead of 200. It remains outside this repair and is not counted green. Initial hook attempts also exposed an untouched Desktop inventory ordering assertion and unidentified ACP/agent test failures; standalone package reruns and the final complete hooks passed without changing those packages. Causes are not established. The previous Desktop CI shard failed before tests on a Hermit `just` download (HTTP 500). New-head CI/security review and reviewer disposition remain required. No deployment, fresh interactive Desktop session, full local `just ci`, or multi-replica runtime validation claimed. Postgres/Redis validation used task-owned local containers, fresh identities, and disposable fixtures. ## Compatibility No automatic cleanup of existing workflows: repeating deletion repairs definitions left by the old path. Canonical UUID coordinates and legacy name fallback (`LIMIT 1` for duplicate names) are unchanged. Backdated recreation remains intentional per [Tyler’s decision](https://github.com/block/buzz/pull/7735#issuecomment-5729881295), not an irreversible tombstone. Mention-index failure now rolls back the complete deletion rather than best-effort accepting partial work. Detailed reviewer disposition and evidence: https://github.com/block/buzz/pull/7735#issuecomment-5737367825 --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Mordecai <f314b6033b6e13df94c8e9e1187d3666b6b9ce54e66ee986e31cdaa909e8dcbc@buzz.block.builderlab.xyz>
781 lines
27 KiB
Rust
781 lines
27 KiB
Rust
//! Relay-backed regression coverage for workflow deletion.
|
|
//!
|
|
//! # Running
|
|
//!
|
|
//! Start an isolated relay, then run:
|
|
//!
|
|
//! ```text
|
|
//! RELAY_URL=ws://localhost:3000 DATABASE_URL=postgres://… cargo test \
|
|
//! -p buzz-test-client --test e2e_workflow_deletion -- --ignored
|
|
//! ```
|
|
|
|
use std::panic::AssertUnwindSafe;
|
|
use std::time::Duration;
|
|
|
|
use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError};
|
|
use futures_util::FutureExt;
|
|
use nostr::{Alphabet, Event, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
const KIND_WORKFLOW_DEFINITION: u16 = 30_620;
|
|
const KIND_WORKFLOW_TRIGGER: u16 = 46_020;
|
|
|
|
fn relay_url() -> String {
|
|
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
|
}
|
|
|
|
fn database_url() -> String {
|
|
std::env::var("DATABASE_URL").expect("DATABASE_URL required for failure injection")
|
|
}
|
|
|
|
fn sub_id(phase: &str) -> String {
|
|
format!("e2e-workflow-deletion-{phase}-{}", Uuid::new_v4())
|
|
}
|
|
|
|
fn definition_filter(keys: &Keys, workflow_id: &str) -> Filter {
|
|
Filter::new()
|
|
.kind(Kind::Custom(KIND_WORKFLOW_DEFINITION))
|
|
.author(keys.public_key())
|
|
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [workflow_id])
|
|
}
|
|
|
|
async fn query_definition(
|
|
client: &mut BuzzTestClient,
|
|
keys: &Keys,
|
|
workflow_id: &str,
|
|
) -> Vec<Event> {
|
|
let subscription = sub_id("definition-query");
|
|
client
|
|
.subscribe(&subscription, vec![definition_filter(keys, workflow_id)])
|
|
.await
|
|
.expect("subscribe for fresh workflow definition query");
|
|
let events = client
|
|
.collect_until_eose(&subscription, Duration::from_secs(5))
|
|
.await
|
|
.expect("query workflow definition");
|
|
client
|
|
.close_subscription(&subscription)
|
|
.await
|
|
.expect("close definition query");
|
|
events
|
|
}
|
|
|
|
async fn query_deletion(client: &mut BuzzTestClient, deletion: &Event) -> Vec<Event> {
|
|
let subscription = sub_id("deletion-query");
|
|
client
|
|
.subscribe(
|
|
&subscription,
|
|
vec![Filter::new().kind(Kind::EventDeletion).id(deletion.id)],
|
|
)
|
|
.await
|
|
.expect("fresh exact-ID deletion query");
|
|
let events = client
|
|
.collect_until_eose(&subscription, Duration::from_secs(5))
|
|
.await
|
|
.expect("query deletion history");
|
|
client
|
|
.close_subscription(&subscription)
|
|
.await
|
|
.expect("close deletion query");
|
|
events
|
|
}
|
|
|
|
async fn database_representation_counts(
|
|
pool: &PgPool,
|
|
keys: &Keys,
|
|
workflow_id: Uuid,
|
|
) -> (i64, i64) {
|
|
let owner = keys.public_key().to_bytes().to_vec();
|
|
let d_tag = workflow_id.to_string();
|
|
let workflow_rows: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM workflows WHERE id = $1 AND owner_pubkey = $2")
|
|
.bind(workflow_id)
|
|
.bind(&owner)
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("count executable workflow representation");
|
|
let definition_rows: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM events WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 \
|
|
AND deleted_at IS NULL",
|
|
)
|
|
.bind(i32::from(KIND_WORKFLOW_DEFINITION))
|
|
.bind(&owner)
|
|
.bind(d_tag)
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("count visible workflow definition representation");
|
|
(workflow_rows, definition_rows)
|
|
}
|
|
|
|
struct FailureInjector {
|
|
pool: PgPool,
|
|
}
|
|
|
|
impl FailureInjector {
|
|
async fn install(pool: PgPool, keys: &Keys, workflow_id: Uuid) -> Self {
|
|
// This fixture runs only against a dedicated disposable database. The
|
|
// shared trigger is inert unless its private table contains this test's
|
|
// exact owner + d-tag coordinate.
|
|
sqlx::raw_sql(
|
|
"CREATE TABLE workflow_delete_failure_injection (\
|
|
owner_pubkey BYTEA NOT NULL, d_tag TEXT NOT NULL, \
|
|
PRIMARY KEY (owner_pubkey, d_tag)\
|
|
); \
|
|
CREATE FUNCTION reject_injected_workflow_delete() RETURNS trigger \
|
|
LANGUAGE plpgsql AS $$ \
|
|
BEGIN \
|
|
IF OLD.kind = 30620 AND OLD.deleted_at IS NULL \
|
|
AND NEW.deleted_at IS NOT NULL \
|
|
AND EXISTS (SELECT 1 FROM workflow_delete_failure_injection \
|
|
WHERE owner_pubkey = OLD.pubkey AND d_tag = OLD.d_tag) \
|
|
THEN RAISE EXCEPTION 'injected workflow deletion failure'; \
|
|
END IF; \
|
|
RETURN NEW; \
|
|
END $$; \
|
|
CREATE TRIGGER reject_injected_workflow_delete \
|
|
BEFORE UPDATE ON events FOR EACH ROW \
|
|
EXECUTE FUNCTION reject_injected_workflow_delete();",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("install coordinate-scoped deletion failure injector");
|
|
sqlx::query(
|
|
"INSERT INTO workflow_delete_failure_injection (owner_pubkey, d_tag) VALUES ($1, $2)",
|
|
)
|
|
.bind(keys.public_key().to_bytes().to_vec())
|
|
.bind(workflow_id.to_string())
|
|
.execute(&pool)
|
|
.await
|
|
.expect("arm deletion failure injector for test coordinate");
|
|
Self { pool }
|
|
}
|
|
|
|
async fn remove(&mut self) {
|
|
sqlx::raw_sql(
|
|
"DROP TRIGGER IF EXISTS reject_injected_workflow_delete ON events; \
|
|
DROP FUNCTION IF EXISTS reject_injected_workflow_delete(); \
|
|
DROP TABLE IF EXISTS workflow_delete_failure_injection;",
|
|
)
|
|
.execute(&self.pool)
|
|
.await
|
|
.expect("remove deletion failure injector");
|
|
}
|
|
}
|
|
|
|
async fn create_channel(client: &mut BuzzTestClient, keys: &Keys, channel_id: &str) {
|
|
let create_channel = EventBuilder::new(Kind::Custom(9007), "")
|
|
.tags([
|
|
Tag::parse(["h", channel_id]).expect("channel h tag"),
|
|
Tag::parse(["name", "workflow-deletion-e2e"]).expect("channel name tag"),
|
|
Tag::parse(["channel_type", "stream"]).expect("channel type tag"),
|
|
Tag::parse(["visibility", "open"]).expect("channel visibility tag"),
|
|
])
|
|
.sign_with_keys(keys)
|
|
.expect("sign channel creation");
|
|
let created = client
|
|
.send_event(create_channel)
|
|
.await
|
|
.expect("create channel");
|
|
assert!(
|
|
created.accepted,
|
|
"channel creation rejected: {}",
|
|
created.message
|
|
);
|
|
}
|
|
|
|
fn workflow_definition(channel_id: &str, workflow_id: &str) -> EventBuilder {
|
|
EventBuilder::new(
|
|
Kind::Custom(KIND_WORKFLOW_DEFINITION),
|
|
"name: deletion-regression\ntrigger:\n on: message_posted\nsteps:\n - id: pause\n action: delay\n duration: 1s\n",
|
|
)
|
|
.tags([
|
|
Tag::parse(["d", workflow_id]).expect("workflow d tag"),
|
|
Tag::parse(["h", channel_id]).expect("workflow h tag"),
|
|
])
|
|
}
|
|
|
|
async fn create_workflow(
|
|
client: &mut BuzzTestClient,
|
|
keys: &Keys,
|
|
channel_id: &str,
|
|
workflow_id: &str,
|
|
) -> nostr::EventId {
|
|
let definition = workflow_definition(channel_id, workflow_id)
|
|
.sign_with_keys(keys)
|
|
.expect("sign workflow definition");
|
|
let definition_id = definition.id;
|
|
let created = client
|
|
.send_event(definition)
|
|
.await
|
|
.expect("create workflow");
|
|
assert!(
|
|
created.accepted,
|
|
"workflow creation rejected: {}",
|
|
created.message
|
|
);
|
|
definition_id
|
|
}
|
|
|
|
fn workflow_deletion(keys: &Keys, workflow_id: &str) -> Event {
|
|
let coordinate = format!(
|
|
"{KIND_WORKFLOW_DEFINITION}:{}:{workflow_id}",
|
|
keys.public_key().to_hex()
|
|
);
|
|
EventBuilder::new(Kind::EventDeletion, "")
|
|
.tags([Tag::parse(["a", coordinate.as_str()]).expect("workflow coordinate")])
|
|
.sign_with_keys(keys)
|
|
.expect("sign workflow deletion")
|
|
}
|
|
|
|
async fn assert_trigger_rejected(client: &mut BuzzTestClient, keys: &Keys, workflow_id: &str) {
|
|
let trigger = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER), "{}")
|
|
.tags([Tag::parse(["d", workflow_id]).expect("trigger d tag")])
|
|
.sign_with_keys(keys)
|
|
.expect("sign workflow trigger");
|
|
let triggered = client
|
|
.send_event(trigger)
|
|
.await
|
|
.expect("submit post-deletion trigger");
|
|
assert!(!triggered.accepted, "deleted workflow remained triggerable");
|
|
assert!(
|
|
triggered.message.contains("workflow not found"),
|
|
"unexpected post-deletion trigger rejection: {}",
|
|
triggered.message
|
|
);
|
|
}
|
|
|
|
async fn assert_no_live_deletion(listener: &mut BuzzTestClient) {
|
|
assert!(
|
|
matches!(
|
|
listener.recv_event(Duration::from_millis(300)).await,
|
|
Err(TestClientError::Timeout)
|
|
),
|
|
"rejected/completed duplicate deletion must not be delivered"
|
|
);
|
|
}
|
|
|
|
async fn assert_live_event(listener: &mut BuzzTestClient, subscription: &str, expected: &Event) {
|
|
match listener
|
|
.recv_event(Duration::from_secs(5))
|
|
.await
|
|
.expect("receive live event")
|
|
{
|
|
RelayMessage::Event {
|
|
subscription_id,
|
|
event,
|
|
} => {
|
|
assert_eq!(subscription_id, subscription);
|
|
assert_eq!(*event, *expected, "dispatch the exact signed event");
|
|
}
|
|
other => panic!("expected live event, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
async fn audit_count(pool: &PgPool, event: &Event) -> i64 {
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM audit_log WHERE object_id = $1 AND action = 'event_created' AND actor_pubkey = $2 AND detail->>'event_kind' = $3")
|
|
.bind(event.id.to_hex())
|
|
.bind(event.pubkey.to_bytes().to_vec())
|
|
.bind(event.kind.as_u16().to_string())
|
|
.fetch_one(pool).await.expect("count event audit entries")
|
|
}
|
|
|
|
async fn flush_audit(client: &mut BuzzTestClient, keys: &Keys, pool: &PgPool) -> Event {
|
|
// Audit enqueue is awaited before OK. A later marker in the same relay's
|
|
// FIFO audit queue gives a causal flush fence instead of an arbitrary sleep.
|
|
let marker = EventBuilder::new(Kind::TextNote, Uuid::new_v4().to_string())
|
|
.sign_with_keys(keys)
|
|
.expect("sign audit fence");
|
|
assert!(
|
|
client
|
|
.send_event(marker.clone())
|
|
.await
|
|
.expect("publish audit fence")
|
|
.accepted
|
|
);
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
while audit_count(pool, &marker).await == 0 {
|
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("audit worker must persist fence");
|
|
marker
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn deleting_workflow_removes_definition_and_rejects_manual_trigger() {
|
|
let keys = Keys::generate();
|
|
let channel_id = Uuid::new_v4().to_string();
|
|
let workflow_id = Uuid::new_v4().to_string();
|
|
let mut client = BuzzTestClient::connect(&relay_url(), &keys)
|
|
.await
|
|
.expect("connect");
|
|
|
|
create_channel(&mut client, &keys, &channel_id).await;
|
|
let definition_id = create_workflow(&mut client, &keys, &channel_id, &workflow_id).await;
|
|
|
|
let before = query_definition(&mut client, &keys, &workflow_id).await;
|
|
assert_eq!(
|
|
before.len(),
|
|
1,
|
|
"definition must be queryable before deletion"
|
|
);
|
|
assert_eq!(before[0].id, definition_id);
|
|
|
|
let deletion = workflow_deletion(&keys, &workflow_id);
|
|
let deleted = client.send_event(deletion).await.expect("delete workflow");
|
|
assert!(
|
|
deleted.accepted,
|
|
"workflow deletion rejected: {}",
|
|
deleted.message
|
|
);
|
|
|
|
let after = query_definition(&mut client, &keys, &workflow_id).await;
|
|
assert!(
|
|
after.is_empty(),
|
|
"deleted workflow definition remained queryable: {after:?}"
|
|
);
|
|
|
|
assert_trigger_rejected(&mut client, &keys, &workflow_id).await;
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
async fn run_failed_deletion_replay_scenario(pool: &PgPool) {
|
|
let keys = Keys::generate();
|
|
let channel_id = Uuid::new_v4().to_string();
|
|
let workflow_id = Uuid::new_v4();
|
|
let workflow_id_text = workflow_id.to_string();
|
|
let mut client = BuzzTestClient::connect(&relay_url(), &keys)
|
|
.await
|
|
.expect("connect");
|
|
|
|
create_channel(&mut client, &keys, &channel_id).await;
|
|
create_workflow(&mut client, &keys, &channel_id, &workflow_id_text).await;
|
|
assert_eq!(
|
|
database_representation_counts(pool, &keys, workflow_id).await,
|
|
(1, 1),
|
|
"workflow setup must create both representations"
|
|
);
|
|
|
|
let deletion = workflow_deletion(&keys, &workflow_id_text);
|
|
let deletion_id = deletion.id;
|
|
let mut listener = BuzzTestClient::connect(&relay_url(), &keys)
|
|
.await
|
|
.expect("connect live listener");
|
|
let subscription = sub_id("live-deletion");
|
|
listener
|
|
.subscribe(
|
|
&subscription,
|
|
vec![Filter::new()
|
|
.kinds([Kind::EventDeletion, Kind::TextNote])
|
|
.author(keys.public_key())],
|
|
)
|
|
.await
|
|
.expect("subscribe before failure");
|
|
assert!(listener
|
|
.collect_until_eose(&subscription, Duration::from_secs(5))
|
|
.await
|
|
.expect("establish live subscription")
|
|
.is_empty());
|
|
let mut injector = FailureInjector::install(pool.clone(), &keys, workflow_id).await;
|
|
let rejected = client
|
|
.send_event(deletion.clone())
|
|
.await
|
|
.expect("submit injected-failure deletion");
|
|
assert!(
|
|
!rejected.accepted,
|
|
"injected deletion failure was acknowledged"
|
|
);
|
|
// WebSocket ingestion deliberately redacts internal database errors.
|
|
assert_eq!(rejected.message, "error: internal server error");
|
|
let marker = flush_audit(&mut client, &keys, pool).await;
|
|
assert_live_event(&mut listener, &subscription, &marker).await;
|
|
assert_no_live_deletion(&mut listener).await;
|
|
assert_eq!(
|
|
audit_count(pool, &deletion).await,
|
|
0,
|
|
"failed deletion must not be audited as accepted"
|
|
);
|
|
|
|
assert_eq!(
|
|
database_representation_counts(pool, &keys, workflow_id).await,
|
|
(1, 1),
|
|
"failed deletion must retain both workflow representations"
|
|
);
|
|
let after_failure = query_definition(&mut client, &keys, &workflow_id_text).await;
|
|
assert_eq!(
|
|
after_failure.len(),
|
|
1,
|
|
"definition disappeared after rejected deletion"
|
|
);
|
|
|
|
let stored_requests: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE id = $1")
|
|
.bind(deletion_id.to_bytes().to_vec())
|
|
.fetch_one(pool)
|
|
.await
|
|
.expect("count stored deletion");
|
|
assert_eq!(
|
|
stored_requests, 0,
|
|
"rejected deletion must roll back its public event"
|
|
);
|
|
assert!(
|
|
query_deletion(&mut client, &deletion).await.is_empty(),
|
|
"rejected deletion must not appear in fresh REQ history"
|
|
);
|
|
injector.remove().await;
|
|
let replayed = client
|
|
.send_event(deletion.clone())
|
|
.await
|
|
.expect("replay identical signed deletion");
|
|
assert!(
|
|
replayed.accepted,
|
|
"identical deletion replay rejected: {}",
|
|
replayed.message
|
|
);
|
|
assert_eq!(replayed.event_id, deletion_id.to_hex());
|
|
assert_eq!(
|
|
query_deletion(&mut client, &deletion).await,
|
|
vec![deletion.clone()]
|
|
);
|
|
assert_live_event(&mut listener, &subscription, &deletion).await;
|
|
let marker = flush_audit(&mut client, &keys, pool).await;
|
|
assert_live_event(&mut listener, &subscription, &marker).await;
|
|
assert_eq!(
|
|
audit_count(pool, &deletion).await,
|
|
1,
|
|
"repair must enqueue normal audit exactly once"
|
|
);
|
|
assert_eq!(
|
|
database_representation_counts(pool, &keys, workflow_id).await,
|
|
(0, 0),
|
|
"successful replay must remove both workflow representations"
|
|
);
|
|
assert!(
|
|
query_definition(&mut client, &keys, &workflow_id_text)
|
|
.await
|
|
.is_empty(),
|
|
"definition remained queryable after successful replay"
|
|
);
|
|
assert_trigger_rejected(&mut client, &keys, &workflow_id_text).await;
|
|
let duplicate = client
|
|
.send_event(deletion.clone())
|
|
.await
|
|
.expect("repeat completed deletion");
|
|
assert!(duplicate.accepted && duplicate.message.starts_with("duplicate:"));
|
|
let marker = flush_audit(&mut client, &keys, pool).await;
|
|
assert_live_event(&mut listener, &subscription, &marker).await;
|
|
assert_no_live_deletion(&mut listener).await;
|
|
assert_eq!(
|
|
audit_count(pool, &deletion).await,
|
|
1,
|
|
"completed duplicate must not re-audit"
|
|
);
|
|
listener.disconnect().await.expect("disconnect listener");
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn failed_workflow_deletion_rolls_back_and_identical_event_can_be_replayed() {
|
|
let pool = PgPool::connect(&database_url())
|
|
.await
|
|
.expect("connect to isolated workflow-deletion database");
|
|
let result = AssertUnwindSafe(run_failed_deletion_replay_scenario(&pool))
|
|
.catch_unwind()
|
|
.await;
|
|
|
|
// Clean up even when the scenario panics so a failed run cannot poison the
|
|
// disposable relay for subsequent validation.
|
|
sqlx::raw_sql(
|
|
"DROP TRIGGER IF EXISTS reject_injected_workflow_delete ON events; \
|
|
DROP FUNCTION IF EXISTS reject_injected_workflow_delete(); \
|
|
DROP TABLE IF EXISTS workflow_delete_failure_injection;",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("final deletion failure injector cleanup");
|
|
|
|
if let Err(panic) = result {
|
|
std::panic::resume_unwind(panic);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn workflow_can_be_intentionally_recreated_with_a_backdated_definition() {
|
|
let keys = Keys::generate();
|
|
let channel_id = Uuid::new_v4().to_string();
|
|
let workflow_id = Uuid::new_v4();
|
|
let workflow_id_text = workflow_id.to_string();
|
|
let mut client = BuzzTestClient::connect(&relay_url(), &keys)
|
|
.await
|
|
.expect("connect");
|
|
create_channel(&mut client, &keys, &channel_id).await;
|
|
create_workflow(&mut client, &keys, &channel_id, &workflow_id_text).await;
|
|
let deletion = workflow_deletion(&keys, &workflow_id_text);
|
|
let backdated = workflow_definition(&channel_id, &workflow_id_text)
|
|
.custom_created_at(Timestamp::from(deletion.created_at.as_secs() - 60))
|
|
.sign_with_keys(&keys)
|
|
.expect("sign intentional backdated recreation");
|
|
assert!(client.send_event(deletion).await.expect("delete").accepted);
|
|
assert!(query_definition(&mut client, &keys, &workflow_id_text)
|
|
.await
|
|
.is_empty());
|
|
let recreated = client
|
|
.send_event(backdated.clone())
|
|
.await
|
|
.expect("recreate backdated workflow");
|
|
assert!(
|
|
recreated.accepted,
|
|
"backdating is intentional: {}",
|
|
recreated.message
|
|
);
|
|
let definitions = query_definition(&mut client, &keys, &workflow_id_text).await;
|
|
assert_eq!(definitions.len(), 1);
|
|
assert_eq!(definitions[0].id, backdated.id);
|
|
let pool = PgPool::connect(&database_url())
|
|
.await
|
|
.expect("isolated database");
|
|
assert_eq!(
|
|
database_representation_counts(&pool, &keys, workflow_id).await,
|
|
(1, 1)
|
|
);
|
|
let trigger = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER), "{}")
|
|
.tags([Tag::parse(["d", workflow_id_text.as_str()]).expect("trigger coordinate")])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign recreated workflow trigger");
|
|
assert!(
|
|
client
|
|
.send_event(trigger)
|
|
.await
|
|
.expect("trigger recreated workflow")
|
|
.accepted
|
|
);
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
async fn concurrent_deletion_scenario(
|
|
pool: &PgPool,
|
|
barrier: &mut Option<sqlx::Transaction<'_, sqlx::Postgres>>,
|
|
tasks: &mut tokio::task::JoinSet<Result<buzz_test_client::OkResponse, TestClientError>>,
|
|
) {
|
|
let keys = Keys::generate();
|
|
let channel_id = Uuid::new_v4().to_string();
|
|
let workflow_id = Uuid::new_v4();
|
|
let workflow_id_text = workflow_id.to_string();
|
|
let mut client = BuzzTestClient::connect(&relay_url(), &keys)
|
|
.await
|
|
.expect("connect");
|
|
create_channel(&mut client, &keys, &channel_id).await;
|
|
create_workflow(&mut client, &keys, &channel_id, &workflow_id_text).await;
|
|
let deletion = EventBuilder::new(Kind::EventDeletion, "")
|
|
.tags([
|
|
Tag::parse([
|
|
"a",
|
|
&format!("30620:{}:{workflow_id}", keys.public_key().to_hex()),
|
|
])
|
|
.expect("coordinate"),
|
|
Tag::public_key(Keys::generate().public_key()),
|
|
])
|
|
.sign_with_keys(&keys)
|
|
.expect("sign deletion");
|
|
let mut listener = BuzzTestClient::connect(&relay_url(), &keys)
|
|
.await
|
|
.expect("listener");
|
|
let subscription = sub_id("concurrent-listener");
|
|
listener
|
|
.subscribe(
|
|
&subscription,
|
|
vec![Filter::new()
|
|
.kinds([Kind::EventDeletion, Kind::TextNote])
|
|
.author(keys.public_key())],
|
|
)
|
|
.await
|
|
.expect("subscribe before concurrent requests");
|
|
assert!(listener
|
|
.collect_until_eose(&subscription, Duration::from_secs(5))
|
|
.await
|
|
.expect("listener EOSE")
|
|
.is_empty());
|
|
|
|
// Block A's first-insert-only mention indexing. In the old split path its
|
|
// public event is already committed and B can repair while A is held. In
|
|
// the atomic path B must wait on A's uncommitted unique event insertion.
|
|
// Both orderings are causally observed in Postgres, never guessed by sleep.
|
|
let lock_key = 7_735_306_205_i64;
|
|
*barrier = Some(pool.begin().await.expect("barrier transaction"));
|
|
let held = barrier.as_mut().expect("barrier exists");
|
|
let controller: i32 = sqlx::query_scalar("SELECT pg_backend_pid()")
|
|
.fetch_one(&mut **held)
|
|
.await
|
|
.expect("controller pid");
|
|
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
|
.bind(lock_key)
|
|
.execute(&mut **held)
|
|
.await
|
|
.expect("hold indexing barrier");
|
|
// Interpolated values are a fixed integer and an EventId's hex encoding,
|
|
// neither of which can contain SQL syntax.
|
|
sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
|
|
"CREATE FUNCTION block_workflow_deletion_index() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
BEGIN IF NEW.event_id = decode('{}', 'hex') THEN
|
|
PERFORM pg_advisory_xact_lock({lock_key});
|
|
END IF; RETURN NEW; END $$;
|
|
CREATE TRIGGER block_workflow_deletion_index BEFORE INSERT ON event_mentions
|
|
FOR EACH ROW EXECUTE FUNCTION block_workflow_deletion_index();",
|
|
deletion.id.to_hex()
|
|
)))
|
|
.execute(pool)
|
|
.await
|
|
.expect("install event-scoped indexing barrier");
|
|
|
|
let mut second_client = BuzzTestClient::connect(&relay_url(), &keys)
|
|
.await
|
|
.expect("second publisher");
|
|
let first_event = deletion.clone();
|
|
tasks.spawn(async move { client.send_event(first_event).await });
|
|
let first_pid: i32 =
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
let pid = sqlx::query_scalar::<_, i32>(
|
|
"SELECT pid FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)) LIMIT 1")
|
|
.bind(controller).fetch_optional(pool).await.expect("find held first ingest");
|
|
if let Some(pid) = pid {
|
|
break pid;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("first ingest must reach indexing barrier");
|
|
let second_event = deletion.clone();
|
|
let second = tasks.spawn(async move { second_client.send_event(second_event).await });
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
loop {
|
|
let blocked: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid)))")
|
|
.bind(first_pid).fetch_one(pool).await.expect("observe second ingest wait");
|
|
if blocked || second.is_finished() { break; }
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
}).await.expect("second ingest must either block on A or finish its repair");
|
|
barrier
|
|
.take()
|
|
.expect("held barrier")
|
|
.commit()
|
|
.await
|
|
.expect("release first ingest");
|
|
let first = tasks
|
|
.join_next()
|
|
.await
|
|
.expect("first task")
|
|
.expect("join first")
|
|
.expect("first response");
|
|
let second = tasks
|
|
.join_next()
|
|
.await
|
|
.expect("second task")
|
|
.expect("join second")
|
|
.expect("second response");
|
|
assert!(
|
|
first.accepted && second.accepted,
|
|
"both requests accepted: {first:?}, {second:?}"
|
|
);
|
|
let mut client = BuzzTestClient::connect(&relay_url(), &keys)
|
|
.await
|
|
.expect("verification client");
|
|
let marker = flush_audit(&mut client, &keys, pool).await;
|
|
assert_eq!(
|
|
audit_count(pool, &deletion).await,
|
|
1,
|
|
"concurrent ingests must have one dispatch/audit owner"
|
|
);
|
|
assert_eq!(
|
|
[&first, &second]
|
|
.iter()
|
|
.filter(|response| response.message.is_empty())
|
|
.count(),
|
|
1,
|
|
"exactly one ingest owns normal acceptance"
|
|
);
|
|
assert_eq!(
|
|
[&first, &second]
|
|
.iter()
|
|
.filter(|response| response.message.starts_with("duplicate:"))
|
|
.count(),
|
|
1,
|
|
"the other ingest must be a completed duplicate"
|
|
);
|
|
// Fan-out tasks can complete out of order; require each exact event once.
|
|
let mut expected = vec![deletion.clone(), marker];
|
|
for _ in 0..2 {
|
|
match listener
|
|
.recv_event(Duration::from_secs(5))
|
|
.await
|
|
.expect("receive deletion/fence")
|
|
{
|
|
RelayMessage::Event {
|
|
subscription_id,
|
|
event,
|
|
} => {
|
|
assert_eq!(subscription_id, subscription);
|
|
let index = expected
|
|
.iter()
|
|
.position(|candidate| candidate == event.as_ref())
|
|
.expect("one copy of each exact deletion/fence event");
|
|
expected.remove(index);
|
|
}
|
|
other => panic!("expected deletion/fence, got {other:?}"),
|
|
}
|
|
}
|
|
assert_no_live_deletion(&mut listener).await;
|
|
assert_eq!(query_deletion(&mut client, &deletion).await, vec![deletion]);
|
|
assert_eq!(
|
|
database_representation_counts(pool, &keys, workflow_id).await,
|
|
(0, 0)
|
|
);
|
|
assert!(query_definition(&mut client, &keys, &workflow_id_text)
|
|
.await
|
|
.is_empty());
|
|
assert_trigger_rejected(&mut client, &keys, &workflow_id_text).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn concurrent_identical_deletions_dispatch_and_audit_once() {
|
|
let pool = PgPool::connect(&database_url())
|
|
.await
|
|
.expect("isolated database");
|
|
let mut barrier = None;
|
|
let mut tasks = tokio::task::JoinSet::new();
|
|
let result = AssertUnwindSafe(concurrent_deletion_scenario(
|
|
&pool,
|
|
&mut barrier,
|
|
&mut tasks,
|
|
))
|
|
.catch_unwind()
|
|
.await;
|
|
// Release blocked server transactions before dropping a trigger they use.
|
|
if let Some(barrier) = barrier {
|
|
barrier
|
|
.rollback()
|
|
.await
|
|
.expect("release failed scenario barrier");
|
|
}
|
|
tasks.abort_all();
|
|
while tasks.join_next().await.is_some() {}
|
|
sqlx::raw_sql(
|
|
"DROP TRIGGER IF EXISTS block_workflow_deletion_index ON event_mentions;
|
|
DROP FUNCTION IF EXISTS block_workflow_deletion_index();",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("remove indexing barrier");
|
|
if let Err(panic) = result {
|
|
std::panic::resume_unwind(panic);
|
|
}
|
|
}
|