Keep two changes to the catalogue made at once from undoing each other (#153)

Each change to the catalogue or the server settings stored the whole thing from a copy it took
earlier, and nothing kept two from overlapping. Adding a feed copied it first and then spent up to
a minute finding and reading the feed, so another person's new feed, an unsubscribe, an admin's
settings, or the scan's own changes (a moved feed's address, a category) made in that minute were
stored over when it finished, with no error. A feed dropped that way left its subscriber's list.

Every change now takes its copy through Ctx::edit_cfg, which holds a lock until store_cfg keeps it,
and store_cfg takes nothing else, so no writer can skip it. Adding a feed takes its copy once it
has found the feed, and before the duplicate check, so two people adding one feed add it once.
reload takes the lock too. `ipx add`, `rm` and `import`, in a process of their own, are outside it,
as the comment on the lock says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 01:40:17 +00:00
parent d5edead011
commit c426ab6314
3 changed files with 74 additions and 14 deletions

View File

@@ -1457,12 +1457,13 @@ async fn add_feed(
user: crate::db::User,
Json(body): Json<NewFeed>,
) -> Result<Json<serde_json::Value>, ApiError> {
let mut cfg = (*state.ctx.cfg()).clone();
// Before the duplicate check, so a site's page finds the feed someone already has. What is
// not a feed and links none is refused here, with why, and nothing is added.
let url = crate::feed::find_feed(&state.ctx.client, &crate::feed::expand_input(&body.url))
.await
.map_err(|e| ApiError::bad_request(format!("{e:#}")))?;
// Taken before the duplicate check, so two people adding the same feed at once add it once.
let mut cfg = state.ctx.edit_cfg().await;
// Someone else may already have it. Then adding costs nothing: no second fetch, no
// second copy on disk, just another name against the same feed.
if let Some(existing) = crate::subscriptions(&state.ctx).await?
@@ -1600,7 +1601,7 @@ async fn patch_feed(
"the feed's address, folder, schedule and category are the same for everyone, so only an admin changes them",
));
}
let mut cfg = (*state.ctx.cfg()).clone();
let mut cfg = state.ctx.edit_cfg().await;
// Derived feeds have no config entry. Editing one is the moment it earns a real
// entry: promote it, so the config holds your decisions and nothing else.
@@ -1677,7 +1678,7 @@ async fn remove_feed(
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
// someone subscribes again they do not pull the back catalogue a second time. A feed an
// admin listed stays in the Directory, for the next person.
let mut cfg = (*state.ctx.cfg()).clone();
let mut cfg = state.ctx.edit_cfg().await;
if cfg.feeds.get(&id).is_some_and(|f| f.listed) {
return Ok(StatusCode::NO_CONTENT);
}
@@ -2156,7 +2157,7 @@ async fn patch_settings(
if !user.is_admin {
return Err(ApiError::forbidden("only an admin changes these settings"));
}
let mut cfg = (*state.ctx.cfg()).clone();
let mut cfg = state.ctx.edit_cfg().await;
if let Some(sched) = body.schedule {
let sched = sched.trim().to_owned();
if crate::config::parse_interval(&sched).is_none() {