Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/bench/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ pub(crate) struct Item {
/// next one posts without the p tag. A new Drop starts false.
#[serde(default)]
pub quiet_repost: bool,
/// Its card left the relay (retired for a refresh, deleted by hand) and
/// Its card is gone (retired for a refresh, deleted by hand) and
/// has not posted again: it keeps its `MAX_CARDS` place till it does.
#[serde(default)]
pub repost: bool,
Expand Down Expand Up @@ -1013,8 +1013,8 @@ pub(crate) fn render_card(item: &Item, hhmm: &str) -> String {
out
}

/// A stale card kept for what is on it (publish (b)), from its text as
/// posted: closed, it no longer says how to answer, and says where to now.
/// An old post (`replaced`), from what it shows: closed, it no longer says how
/// to answer, and says where to now.
pub(crate) fn render_closed(posted: &str) -> String {
let open = [
format!(" {TAP}"),
Expand Down
8 changes: 4 additions & 4 deletions desktop/src-tauri/src/bench/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,11 @@ pub(crate) struct BenchState {
/// Each bound channel's join or admin check: cards go only where it passed.
channels: BTreeMap<String, channels::ChannelCheck>,
/// Engaged or owner-told cards a new question left behind, and stale ones
/// closed for what is on them: on the relay for good, never adopted again
/// by recover (which would retire them as stale).
/// retired: on the relay for good, never adopted again by recover (which
/// would retire them as stale). 64 bytes each, a few a day at 4 Nov 2026.
kept: Vec<String>,
/// Each kept card posted in a thread, as (its root, its created_at): it
/// still ends the root-reply window of an older card there.
/// Each kept card in a thread with anything on it, as (its root, its
/// created_at): it still ends the root-reply window of an older card there.
kept_at: Vec<(String, u64)>,
/// Old posts owed an edit or a reply or watched for a tap; since when their read fails.
replaced: Vec<replaced::Replaced>,
Expand Down
90 changes: 28 additions & 62 deletions desktop/src-tauri/src/bench/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ use std::sync::{Mutex, PoisonError};

use super::channels::{card_channel, channel_ready, joined, reads_replies};
use super::{
item, live_card, local_hhmm, next_ts, outbox, post_as_writer, query, query_writer, same_drop,
save_both, spend, submit, BenchState, Ctx, Outcome, Posted, BOARD_MAX_AGE_SECS, MAX_CARDS,
RECOVER_LIMIT,
item, live_card, local_hhmm, next_ts, outbox, post_as_writer, query, same_drop, save_both,
spend, submit, BenchState, Ctx, Outcome, Posted, BOARD_MAX_AGE_SECS, MAX_CARDS, RECOVER_LIMIT,
};
use crate::events;

Expand Down Expand Up @@ -337,55 +336,29 @@ fn df_answer(parts: [&str; 3]) -> Result<nostr::Tag, String> {
nostr::Tag::parse(["df-answer", n, by, id]).map_err(|e| e.to_string())
}

/// Whether a recovered card has anything on it but the writer's own seeds:
/// a tap, a reply, a route notice, or a Recorded or receipt edit. A cut page
/// holds more than the four seeds, so it counts too.
async fn touched(
/// What a post has on it but the writer's own seeds: a tap, a reply, a route
/// notice, or a Recorded, receipt or closing edit. A cut page holds more than
/// the four seeds, so it counts too.
pub(super) async fn on_post(
ctx: &Ctx<'_>,
s: &BenchState,
ch: &str,
card: &str,
now: u64,
) -> Result<bool, String> {
) -> Result<Vec<nostr::Event>, String> {
let filter = serde_json::json!({
"kinds": [7, 9, 40003], "#h": [ch], "#e": [card], "limit": RECOVER_LIMIT,
});
let writer = ctx.writer.public_key();
Ok(query(ctx, s, filter, now).await?.iter().any(|e| {
let mut on = query(ctx, s, filter, now).await?;
on.retain(|e| {
let on_card = e
.tags
.iter()
.any(|t| matches!(t.as_slice(), [k, v, ..] if k == "e" && v == card));
on_card && !(e.kind.as_u16() == 7 && e.pubkey == writer)
}))
}

/// A stale card kept for what is on it (`touched`), for good: edited closed
/// from its own text as posted (`replaced::old_post`), its thread root (its
/// parent) ending older cards' root-reply windows there, and watched for a tap
/// (`replaced`). One gone from the relay is only kept.
async fn close(
ctx: &Ctx<'_>,
s: &mut BenchState,
id: &str,
ch: &str,
card: &Posted,
now: u64,
) -> Result<(), String> {
let filter = serde_json::json!({ "ids": [card.event_id], "kinds": [9], "#h": [ch] });
if let Some(e) = query_writer(ctx, s, filter, now).await?.first() {
let text = super::replaced::old_post(s, id, &e.content);
let text = text + &item::thread_line(ch, &card.event_id);
match edit(ctx, s, ch, &card.event_id, &text, now).await {
Err(err) if !err.contains("not found") => return Err(err),
_ => {}
}
let root = item::parent(e).map(|r| (r.to_string(), card.created_at));
s.kept_at.extend(root);
super::replaced::closed(s, id, ch, card, item::parent(e), now);
}
s.kept.push(card.event_id.clone());
Ok(())
});
Ok(on)
}

/// A thread the relay will not take: its root is gone, in another channel or
Expand All @@ -397,9 +370,10 @@ fn bad_thread(e: &str) -> bool {
}

/// Cards, seeds, then the board, at most `budget` posts. A card is posted
/// once per Drop and card format; it is edited to record an answer and, once
/// closed out, to become a receipt. Only a card nobody engaged with is ever
/// deleted. Every post made once a card's own id is known links its thread.
/// once per Drop and card format; it is edited to record an answer, once closed
/// out to become a receipt and once stale to become an old post (`replaced`), so
/// only a recovered orphan nobody touched is ever deleted. Every post made once
/// a card's own id is known links its thread.
/// An owner's notice that fails to go again lands in `failed`, for
/// lastError, and the rest goes on.
#[allow(clippy::too_many_arguments)]
Expand Down Expand Up @@ -704,12 +678,13 @@ pub(super) async fn publish(
s.items.get_mut(&id).expect("routed id").routed_reply = Some(r.event_id.clone());
}

// (b) Delete cards that are stale (another Drop, card format or stamp) or
// (b) Retire cards that are stale (another Drop, card format or stamp) or
// no longer asked (superseded, expired, no longer pending), never for
// MAX_CARDS, unless someone answered or replied on them or they are a
// receipt: those stay on the relay for good, and so, closed, does one
// with anything else on it but its seeds. A card in a channel that is
// not ready, or `held`, waits: its taps are read once it is again.
// receipt: those keep their place. A retired card stays on the relay for
// good, kept from recover and owed its edit closed (`replaced`), so no
// failure here fails the tick. A card in a channel that is not ready, or
// `held`, waits: its taps are read once it is again.
let stale: Vec<(String, String, Posted)> = s
.items
.values()
Expand All @@ -722,36 +697,27 @@ pub(super) async fn publish(
})
.collect();
for (id, ch, card) in stale {
// One post per card, its lookups riding on it: the delete, or the edit
// that closes it.
if !spend(budget, 1) {
return Ok(());
}
// A reply or tap the item never read or no longer holds (free text on
// an unscoped card with no route, or one asked again) still makes the
// card a record: it stays, as `carry_card` keeps one, but closed,
// since nothing reads it any more.
if touched(ctx, s, &ch, &card.event_id, now).await? {
close(ctx, s, &id, &ch, &card, now).await?;
} else {
delete(ctx, s, &ch, &card.event_id, now).await?;
}
super::replaced::retired(s, &id, &ch, &card, now);
s.kept.push(card.event_id);
forget_card(ctx, s, &id);
}

// (c) Post missing cards, adopting a recovered orphan when its hash matches.
// (c) Post missing cards, adopting a recovered orphan when its hash matches
// and it was not edited closed: that post says a tap there doesn't count.
for id in &wanted {
if s.items[id].card.is_some() {
continue;
}
let ch = card_channel(s, &s.items[id]);
if let Some(orphan) = s.orphan_cards.get(id).cloned() {
if orphan.hash == item::card_hash(&s.items[id]) {
let on = on_post(ctx, s, &ch, &orphan.event_id, now).await?;
let closed = on.iter().any(super::replaced::closes);
if orphan.hash == item::card_hash(&s.items[id]) && !closed {
s.items.get_mut(id).expect("wanted id").card = s.orphan_cards.remove(id);
continue;
}
// An answered, replied or receipt card stays on the relay for good.
if touched(ctx, s, &ch, &orphan.event_id, now).await? {
if !on.is_empty() {
s.kept.push(orphan.event_id.clone());
} else {
if !spend(budget, 1) {
Expand Down
82 changes: 64 additions & 18 deletions desktop/src-tauri/src/bench/replaced.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
//! card that was shown, so it never answers, never reaches the outbox and
//! never moves to the new card.
//!
//! None is deleted. A phone that lost its socket across the delete keeps
//! showing the post, the relay refuses a tap on it and the phone's code ignores
//! the refusal, so that tap would vanish with nothing said.
//!
//! Nothing here fails a tick: lastError stops the steward kit's approvals, and
//! the live cards are fine. What keeps failing is on the board (`alarm`).

Expand All @@ -15,7 +19,7 @@ use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};

use super::channels::{card_channel, ready};
use super::publish::{edit, post_message, thread_of};
use super::publish::{edit, on_post, post_message, thread_of};
use super::{
item, post_as_writer, query, query_writer, spend, BenchState, Ctx, Posted, RECOVER_LIMIT,
};
Expand All @@ -30,10 +34,11 @@ const NOT_ASKED: &str = "No longer asked. A tap here doesn't count.";
const WATCH_SECS: u64 = 86_400;
/// Old posts read for taps at once, newest first. One batched /query per tick
/// while any is watched, however many: +30/min of the relay's 300/min (about
/// 5/s), beside `poll_answers`' up to 120. Sized for ten times the churn at 1
/// Nov 2026 (28 drops in a day, 7 cards kept in all, most re-drops deleted as
/// untouched). A cut page keeps its newest events, the ones looked for. Past
/// this the oldest go unread and the board says so (`alarm`).
/// 5/s), beside `poll_answers`' up to 120. Sized for three times the busiest day
/// at 1 Nov 2026, 28 drops if each left an old post; ten times that outgrows it.
/// A cut page keeps its newest events, the ones looked for. Past this the oldest
/// go unread and the board says so (`alarm`); raising it only lengthens the one
/// query's `#e`.
pub(super) const WATCH_MAX: usize = 100;
/// An old post that would not update is tried again this soon.
const RETRY_SECS: u64 = 60;
Expand All @@ -60,6 +65,11 @@ pub(crate) struct Replaced {
pub at: u64,
/// The item as the post shows it, until the post is edited.
pub shown: Option<Box<item::Item>>,
/// Owed its edit, before its seeds, from its own text on the relay: its
/// item already holds the newer Drop (`retired`). With a `root`, edited:
/// what is on it is still to read, for `kept_at`.
#[serde(default)]
pub from_post: bool,
pub seeds_off: bool,
/// Read for a tap: the newest old post of its item, not told yet.
pub watch: bool,
Expand All @@ -83,6 +93,7 @@ impl Replaced {
root: root.map(str::to_string),
at: now,
shown: None,
from_post: false,
seeds_off: false,
watch: true,
seen: Vec::new(),
Expand All @@ -107,8 +118,8 @@ fn add(s: &mut BenchState, r: Replaced) {
}

/// A new Drop left the card of the item it replaces behind (`carry_card`): it
/// has no card, the item still in state does. publish edits it from what it
/// shows, its Recorded line or receipt included.
/// has no card, the item still in state does. Edited from what it shows, its
/// Recorded line or receipt included.
pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) {
let Some(old) = s.items.get(&new.id).filter(|_| new.card.is_none()) else {
return;
Expand All @@ -124,16 +135,20 @@ pub(super) fn left_behind(s: &mut BenchState, new: &item::Item, now: u64) {
add(s, r);
}

/// A stale card publish (b) just edited closed (`old_post`).
pub(super) fn closed(
s: &mut BenchState,
id: &str,
ch: &str,
card: &Posted,
root: Option<&str>,
now: u64,
) {
add(s, Replaced::new(id, ch, card, root, now));
/// A stale card publish (b) just retired: its item holds the newer Drop, so it
/// is edited from its own text as posted.
pub(super) fn retired(s: &mut BenchState, id: &str, ch: &str, card: &Posted, now: u64) {
let r = Replaced {
from_post: true,
..Replaced::new(id, ch, card, None, now)
};
add(s, r);
}

/// Whether `e` is an edit that made a post an old post.
pub(super) fn closes(e: &nostr::Event) -> bool {
let first = e.content.lines().next().unwrap_or_default();
e.kind.as_u16() == 40003 && (first == REPLACED || first == NOT_ASKED)
}

fn asked(i: &item::Item) -> bool {
Expand Down Expand Up @@ -273,7 +288,7 @@ async fn read(ctx: &Ctx<'_>, s: &mut BenchState, now: u64, owner_set: &[String])
/// never comes back from /query, so this resumes), its one reply. Ok(false):
/// the tick's budget or lookups ran out first. The relay is asked before the
/// reply goes, so a crash after it landed and before state saved never sends
/// it twice.
/// it twice. A post no longer there is an Err saying "not found" (`tick`).
async fn finish(
ctx: &Ctx<'_>,
s: &mut BenchState,
Expand All @@ -285,6 +300,32 @@ async fn finish(
) -> Result<bool, String> {
let r = s.replaced[i].clone();
let (ch, card) = (&r.channel, &r.card.event_id);
if r.from_post {
if *budget == 0 || !spend(lookups, 1) {
return Ok(false);
}
// Edited, in a thread: with anything else on it, it ends older cards'
// root-reply windows there, as a kept card does. With nothing, a reply
// naming only the root is still theirs.
if let Some(root) = &r.root {
let on = on_post(ctx, s, ch, card, now).await?;
if on.iter().any(|e| !closes(e)) {
s.kept_at.push((root.clone(), r.card.created_at));
}
s.replaced[i].from_post = false;
return Ok(true);
}
let filter = serde_json::json!({ "ids": [card], "kinds": [9], "#h": [ch] });
let posted = query_writer(ctx, s, filter, now).await?;
let e = posted.first().ok_or("not found: no such post")?;
let text = old_post(s, &r.item, &e.content) + &item::thread_line(ch, card);
spend(budget, 1);
edit(ctx, s, ch, card, &text, now).await?;
let root = item::parent(e).map(str::to_string);
let r = &mut s.replaced[i];
(r.from_post, r.root) = (root.is_some(), root);
return Ok(true);
}
if let Some(shown) = &r.shown {
if !spend(budget, 1) {
return Ok(false);
Expand Down Expand Up @@ -366,6 +407,10 @@ pub(super) async fn tick(
hhmm: &str,
owner_set: &[String],
) {
// Past 30 failing the loop runs out of lookups every tick and never reaches
// the retain below, so one that has failed goes at its window here.
s.replaced
.retain(|r| r.retry_at == 0 || now < r.at + WATCH_SECS);
read(ctx, s, now, owner_set).await;
let mut lookups = LOOKUPS_PER_TICK;
for i in 0..s.replaced.len() {
Expand All @@ -374,6 +419,7 @@ pub(super) async fn tick(
continue;
}
match finish(ctx, s, i, budget, &mut lookups, now, hhmm).await {
// Not `break`: the retain would drop what is past its window untried.
Ok(false) => return,
Ok(true) => s.replaced[i].retry_at = 0,
// Gone from the relay: nothing is left to edit or to reply under.
Expand Down
Loading