-
Notifications
You must be signed in to change notification settings - Fork 53
fix: stop re-publishing the pending orderbook hourly and at startup #888
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
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1067,11 +1067,6 @@ async fn enforce_escrow_deadline_pass( | |
|
|
||
| /// How often the orderbook reconciler drains the failed-publish queue. | ||
| const ORDERBOOK_RECONCILE_INTERVAL_SECS: u64 = 60; | ||
| /// Every this many reconciler ticks, the whole `pending` book is | ||
| /// re-asserted on relays (once per hour at the 60 s tick), healing states | ||
| /// relays lost for reasons the daemon never saw (dropped events, relay | ||
| /// restores from backup, NIP-01 ties lost before the monotonic stamp). | ||
| const ORDERBOOK_FULL_SWEEP_EVERY_TICKS: u64 = 60; | ||
|
|
||
| /// A reconciler republish only proceeds when the order has not been | ||
| /// stamped for this long. Handlers publish their kind-38383 revision | ||
|
|
@@ -1083,18 +1078,17 @@ const ORDERBOOK_FULL_SWEEP_EVERY_TICKS: u64 = 60; | |
| const ORDERBOOK_QUIESCENT_SECS: u64 = 120; | ||
|
|
||
| /// One reconciler pass: republish every order whose last kind-38383 | ||
| /// publish failed (`util::take_failed_orderbook_publishes`), and — when | ||
| /// `full_sweep` — re-assert every live `pending` order from the DB. | ||
| /// publish failed (`util::take_failed_orderbook_publishes`). | ||
|
Member
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. The failed-publish queue only fills on This doc line is now the whole convergence story, so it is worth checking what actually feeds that queue. At match client.send_event(&event).await {
Ok(_) => clear_orderbook_publish_failure_up_to(order.id, stamp.generation),
Err(e) => { warn!(...); mark_orderbook_publish_failed_at(order.id, stamp.generation); }
}In nostr-sdk 0.45.1 ( So a per-relay rejection or timeout lands in That is precisely the gap the hourly sweep was papering over by brute force. Removing the sweep promotes it to the primary divergence path, with nothing left to heal it. The fix is short: Ok(output) if output.failed.is_empty() =>
clear_orderbook_publish_failure_up_to(order.id, stamp.generation),
Ok(output) => {
tracing::warn!(
"orderbook publish rejected by {} relay(s) for order {} (status {}): {:?}; queued for republish",
output.failed.len(), order_updated.id, status, output.failed,
);
mark_orderbook_publish_failed_at(order.id, stamp.generation);
}
Err(e) => { /* unchanged */ }This is pre-existing from #872, so it can land as a companion PR — but it should land with this one or before it, because this PR's risk assessment depends on it. One caveat I will state rather than overclaim: I verified the |
||
| /// | ||
| /// Every publish goes through `update_order_event_if_quiescent`: an order | ||
| /// stamped within the last [`ORDERBOOK_QUIESCENT_SECS`] is skipped (queue | ||
| /// entries are kept for the next pass), so a sweep can never supersede a | ||
| /// transition that published before persisting its CAS. On publish | ||
| /// entries are kept for the next pass), so a republish can never supersede | ||
| /// a transition that published before persisting its CAS. On publish | ||
| /// failure the order re-queues itself, so a relay outage self-heals on a | ||
| /// later pass. The returned order (fresh `event_id`) is deliberately not | ||
| /// persisted, matching the CAS-miss repair path: the DB row's status is | ||
| /// the source of truth being re-advertised, not mutated. | ||
| async fn reconcile_orderbook_once(pool: &sqlx::SqlitePool, keys: &Keys, full_sweep: bool) { | ||
| async fn reconcile_orderbook_once(pool: &sqlx::SqlitePool, keys: &Keys) { | ||
| for (order_id, generation) in util::take_failed_orderbook_publishes() { | ||
| match Order::by_id(pool, order_id).await { | ||
| Ok(Some(order)) => match order.get_order_status() { | ||
|
|
@@ -1130,62 +1124,20 @@ async fn reconcile_orderbook_once(pool: &sqlx::SqlitePool, keys: &Keys, full_swe | |
| } | ||
| } | ||
| } | ||
|
|
||
| if full_sweep { | ||
| match crate::db::find_pending_orders_for_reconcile(pool).await { | ||
| Ok(orders) => { | ||
| info!( | ||
| "orderbook reconciler: re-asserting {} pending order(s) on relays", | ||
| orders.len() | ||
| ); | ||
| for order in orders { | ||
| match order.get_order_status() { | ||
| Ok(status) => { | ||
| match util::update_order_event_if_quiescent( | ||
| keys, | ||
| status, | ||
| &order, | ||
| ORDERBOOK_QUIESCENT_SECS, | ||
| ) | ||
| .await | ||
| { | ||
| Ok(Some(_)) => {} | ||
| // Recently stamped — the normal publish | ||
| // path owns convergence for this order; a | ||
| // lost event is healed by the next sweep. | ||
| Ok(None) => {} | ||
| Err(e) => warn!( | ||
| "orderbook reconciler: re-assert of order {} failed: {e}", | ||
| order.id | ||
| ), | ||
| } | ||
| } | ||
| Err(e) => warn!( | ||
| "orderbook reconciler: order {} has bad status: {e}", | ||
| order.id | ||
| ), | ||
| } | ||
| } | ||
| } | ||
| Err(e) => warn!("orderbook reconciler: could not list pending orders: {e}"), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Keeps the public NIP-33 orderbook converged with the DB: drains the | ||
| /// failed-publish queue every minute and re-asserts the whole pending book | ||
| /// hourly (first sweep right at startup, healing relay state lost across | ||
| /// daemon restarts). Without this job a single dropped publish | ||
| /// leaves a dead order advertised as `pending` until its NIP-40 expiration. | ||
| /// failed-publish queue every minute. Without this job a single dropped | ||
|
Member
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. This removes the only recovery for publishes lost across a restart, and the PR body does not say so.
Two classes lose all coverage:
The body states "Everything else from #872 stays intact, including the two layers that actually keep the book converged". Those two layers do not cover these: the queue only covers observed failures, and NIP-40 caps how long a stale event lives but cannot restore a missing one. To be clear, I think this is a defensible trade — the author of #872 confirms the re-publication was never intended, the harms you list are real, and NIP-40 bounds the blast radius to the order's actual take window. My ask is narrow: state the trade explicitly here and in the body, instead of leaving it implicit behind "everything else stays intact". The deleted comment is the only place it was written down. |
||
| /// publish leaves a dead order advertised as `pending` until its NIP-40 | ||
| /// expiration. Orders whose publishes succeeded are never re-sent: a | ||
| /// kind-38383 revision is published once, when the order actually | ||
| /// transitions. | ||
| async fn job_orderbook_reconciler(ctx: AppContext) { | ||
| let keys = ctx.keys().clone(); | ||
| tokio::spawn(async move { | ||
| let pool = ctx.pool(); | ||
| let mut tick: u64 = 0; | ||
| loop { | ||
| let full_sweep = tick.is_multiple_of(ORDERBOOK_FULL_SWEEP_EVERY_TICKS); | ||
| reconcile_orderbook_once(pool, &keys, full_sweep).await; | ||
| tick = tick.wrapping_add(1); | ||
| reconcile_orderbook_once(pool, &keys).await; | ||
|
Member
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. The first pass at startup is now a guaranteed no-op. (nit) The loop calls Harmless either way — but moving the sleep to the top of the loop, or leaving a one-line comment, saves the next reader from working out what the startup pass is supposed to accomplish. |
||
| tokio::time::sleep(tokio::time::Duration::from_secs( | ||
| ORDERBOOK_RECONCILE_INTERVAL_SECS, | ||
| )) | ||
|
|
@@ -1649,7 +1601,7 @@ mod tests { | |
| let ghost = Uuid::new_v4(); | ||
| crate::util::mark_orderbook_publish_failed(ghost); | ||
|
|
||
| reconcile_orderbook_once(ctx.pool(), &keys, false).await; | ||
| reconcile_orderbook_once(ctx.pool(), &keys).await; | ||
|
Member
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. No test pins the newly documented invariant. The three retained tests all exercise the queue drain — ghost order, requeue while relays are unreachable, quiescence guard. None of them asserts the guarantee this PR newly documents at A cheap regression test closes that: seed a live Deletions are the changes that regress most quietly. Without a test, nothing stops someone from re-adding a periodic sweep in six months for what will look like a perfectly good reason. |
||
|
|
||
| assert!( | ||
| !crate::util::is_orderbook_publish_queued(ghost), | ||
|
|
@@ -1681,7 +1633,7 @@ mod tests { | |
| let order = order.create(ctx.pool()).await.unwrap(); | ||
| crate::util::mark_orderbook_publish_failed(order.id); | ||
|
|
||
| reconcile_orderbook_once(ctx.pool(), &keys, false).await; | ||
| reconcile_orderbook_once(ctx.pool(), &keys).await; | ||
|
|
||
| assert!( | ||
| crate::util::is_orderbook_publish_queued(order.id), | ||
|
|
@@ -1717,7 +1669,7 @@ mod tests { | |
| let _ = crate::util::stamp_orderbook_event(order.id, nostr_sdk::prelude::Timestamp::now()); | ||
| crate::util::mark_orderbook_publish_failed(order.id); | ||
|
|
||
| reconcile_orderbook_once(ctx.pool(), &keys, false).await; | ||
| reconcile_orderbook_once(ctx.pool(), &keys).await; | ||
|
|
||
| assert!( | ||
| crate::util::is_orderbook_publish_queued(order.id), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
created_atchurn has a smaller root cause than the sweep.The first and strongest justification in the body is that every re-assert stamps a fresh
created_atbecause the monotonic registry is in-memory. That is accurate, but the sweep is the symptom rather than the cause:orders.created_atis a persisted column — visible in theINSERTof the very test helper this PR removes;ORDERBOOK_REGISTRY.last_ts(util.rs:960-976) simply starts empty, and its own doc admits it: "Across a restart the map starts empty";update_order_event_stamped(util.rs:1219-1223) always passesTimestamp::now()as the candidate, in bothStampPolicyarms, so nothing ever reuses the persisted value.Seeding the registry from
orders.created_atat startup — or reusing the persistedcreated_atwhen the published status has not changed — makes a re-assert idempotent: samedtag, same content, samecreated_at-> same event id -> relays dedupe it outright. That kills all three harms the body lists (the churn, the 24x/day traffic, and theorders.event_iddrift, which disappears because the id no longer changes) without giving up the healing.Caveat I will name myself:
order_to_tagsincludes reputation data viaget_ratings_for_pending_order, which can legitimately change between re-asserts and would produce a new id. That is a real update, not churn, so it does not undermine the approach.Even if the deletion is still the preferred outcome, this deserves a sentence in the body on why the idempotent-re-assert path was rejected — right now the body reads as though re-publication is inherently churn-producing, and it is not.