Commit Graph

4 Commits

Author SHA1 Message Date
6dd3829cfa Write against the file, not a stale cache; stop indexing conflict files
Some checks failed
CI / test (push) Failing after 6s
CI / image (push) Has been skipped
Two deployment bugs that both present as "the client says done but the
markdown never changes, and nothing errors".

1. upload() diffed the client's VTODO against the cached in-memory task.
   When that cache disagreed with disk the diff came out empty, apply()
   returned early, and Radicale answered 2xx having written nothing.

   The cache goes stale on NFS: refresh_if_stale() compares stat(), and a
   `- [ ]` to `- [x]` toggle does not change the file's size, so mtime is
   the only signal — which NFS attribute caching hides for the length of
   the attr timeout. Add Index.refresh_file(), which compares the actual
   bytes, and call it before diffing.

2. docker/mdcaldav.toml set `exclude`, which REPLACES the built-in list
   rather than extending it, silently dropping the Syncthing patterns.
   Against a real Syncthing vault this reindexes `*.sync-conflict-*` (and
   `.stversions/`, which holds whole historical copies of every note): the
   task list gains duplicates, and completing one can write to a conflict
   file the user never opens while the note they are watching stays put.
   Drop the override so the defaults apply.

Also switch the container to a polling observer. inotify reports nothing
for a network mount written by another host, so `watch = true` was a
silent no-op exactly where it was most wanted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:58:16 -04:00
cbe69ec1d7 Serialize index access between the watcher and request threads
Some checks failed
CI / test (push) Failing after 5s
CI / image (push) Has been skipped
The watcher thread crashed in production with "database is locked", and
under load the damage is worse than a stalled rescan:

    sqlite3.InterfaceError: bad parameter or other API misuse
    TypeError: 'NoneType' object is not subscriptable

Radicale serializes its request threads with Storage.acquire_lock, but the
watcher calls index.rescan() outside it. Both share one sqlite3 connection,
and rescan is a multi-statement read-modify-write — bump the generation,
reparse, reconcile identities, prune, commit. Interleaving two of those
corrupts the reconcile pass and misuses the connection.

Give Index an RLock and take it in every public method. Writer holds it
across validate → write → reindex, since a rescan landing between _load
and the splice would invalidate the byte spans about to be written.

Two supporting fixes:

  - _rebuild_memory assigned an empty dict before refilling it, so a reader
    could observe a half-populated task list. Build, then swap.
  - Open the database in WAL with a 30s busy_timeout, so `mdcaldav doctor`
    run against a live server waits instead of failing.

This is also a second, independent cause of the ETag churn fixed in
2552545: a corrupted reconcile pass reassigns last_modified on tasks that
never changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:13:53 -04:00
2552545265 Fix tasks reverting after a client marks them done
Some checks failed
CI / test (push) Failing after 6s
CI / image (push) Has been skipped
Completing a task in a CalDAV client wrote through to the markdown
correctly, then the client silently un-completed it moments later.

`to_ics` stamped DTSTAMP and LAST-MODIFIED with the wall clock on every
serialization, and Radicale derives an item's ETag by hashing that
serialization. So an unchanged task minted a fresh ETag on every read, at
one-second granularity. A client that read the task, waited for the user
to tick the checkbox, then PUT with `If-Match: <the etag it holds>` always
got 412 Precondition Failed — and resolved that apparent conflict by
re-downloading the server copy, discarding the completion.

Persist a per-task `last_modified` in the sidecar instead, stamped only
when the task's content or status actually changes, and serve DTSTAMP,
LAST-MODIFIED and the Item's own last_modified from it. The ETag is now
stable while the task is, and changes exactly when the task does.

Schema goes to v2, migrated in place with ALTER TABLE: rebuilding the
database would regenerate every UID and replace clients' whole task list.

Also fixes a plain GET of an item returning 500 — Radicale asserts on
`Item.last_modified`, which was never passed. Only REPORT was covered by
tests, so nothing caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:01:33 -04:00
8b5cd370ea Initial commit
Some checks failed
CI / test (push) Failing after 11s
CI / image (push) Has been skipped
2026-08-01 21:03:35 -04:00