Writing a card or an event: a server that answers a PUT without an ETag makes the plugin list the whole collection again #46

Closed
opened 2026-10-31 03:04:31 +00:00 by tiagoagueda · 0 comments
Owner

What exists today

src/postulo_dav/dav.py:383-396 Server.put: when the answer to a PUT carries no ETag, line 395 calls self.etags(href.rsplit("/", 1)[0] + "/"), a Depth 1 PROPFIND of the whole collection (dav.py:347-375), to pick out one resource's ETag. Every caller goes through it: a new card (sync.py:797), a changed card (sync.py:895), a new or changed event (sync.py:1050, 1061). So pushing N records costs N full listings of a collection that grows by one each time.

When does a server omit the ETag? RFC 4791 §5.3.4 and RFC 6352 §6.3.2.3 say a server MUST NOT return a strong ETag when the stored object is not byte-for-byte what was sent. SabreDAV (Nextcloud, Baïkal) drops the header whenever its validator repairs or rewrites the body, and the plugin's first try at an event carries METHOD:PUBLISH (sync.py:1046-1056), which RFC 4791 §4.1 forbids in a stored object; a server that accepts the event and strips the line has changed it, and must not answer with an ETag. So the fallback is the common path for events on lenient servers, not an edge case.

The fallback is not exercised by the tests: the fake server (tests/test_dav.py:45-120) always answers a PUT with an ETag.

What would change

Ask for the one resource: a Depth 0 PROPFIND of href for getetag (the existing _ETAGS body, through propfind(href, _ETAGS, "0")), read with the same href checks, so a write costs one small request whatever the size of the collection. If that answer has no ETag either, store "" as today; the next run then sees a changed ETag, reads the resource once and settles it (sync.py:827-833).

A test: the fake server gains a switch to answer PUT without ETag, and pushing three contacts asserts no Depth 1 PROPFIND after the first listing of the run.

What it saves or protects

medium-large on the servers concerned: a first push of 300 contacts lists the collection 300 times (about 45,000 listing entries, some 9 MB of XML parsed; computed from the code, not measured); one small request per write instead

Accessibility

none

Decisions

  • One Depth 0 request per write, or one listing at the end of the half for every write that lacked an ETag? Recommended: Depth 0 per write. It keeps put() self-contained, and the conflict handling that follows a write already expects the ETag at once.

Found by the efficiency and reliability audit of 2026-10-30 (finding PR6; index postulo/postulo#1108). Checked against main at daee30b15 (2026-10-30).

## What exists today `src/postulo_dav/dav.py:383-396` `Server.put`: when the answer to a `PUT` carries no `ETag`, line 395 calls `self.etags(href.rsplit("/", 1)[0] + "/")`, a Depth 1 `PROPFIND` of the whole collection (`dav.py:347-375`), to pick out one resource's ETag. Every caller goes through it: a new card (`sync.py:797`), a changed card (`sync.py:895`), a new or changed event (`sync.py:1050`, `1061`). So pushing N records costs N full listings of a collection that grows by one each time. When does a server omit the ETag? RFC 4791 §5.3.4 and RFC 6352 §6.3.2.3 say a server MUST NOT return a strong ETag when the stored object is not byte-for-byte what was sent. SabreDAV (Nextcloud, Baïkal) drops the header whenever its validator repairs or rewrites the body, and the plugin's first try at an event carries `METHOD:PUBLISH` (`sync.py:1046-1056`), which RFC 4791 §4.1 forbids in a stored object; a server that accepts the event and strips the line has changed it, and must not answer with an ETag. So the fallback is the common path for events on lenient servers, not an edge case. The fallback is not exercised by the tests: the fake server (`tests/test_dav.py:45-120`) always answers a `PUT` with an `ETag`. ## What would change Ask for the one resource: a Depth 0 `PROPFIND` of `href` for `getetag` (the existing `_ETAGS` body, through `propfind(href, _ETAGS, "0")`), read with the same href checks, so a write costs one small request whatever the size of the collection. If that answer has no ETag either, store `""` as today; the next run then sees a changed ETag, reads the resource once and settles it (`sync.py:827-833`). A test: the fake server gains a switch to answer `PUT` without `ETag`, and pushing three contacts asserts no Depth 1 `PROPFIND` after the first listing of the run. ## What it saves or protects medium-large on the servers concerned: a first push of 300 contacts lists the collection 300 times (about 45,000 listing entries, some 9 MB of XML parsed; computed from the code, not measured); one small request per write instead ## Accessibility none ## Decisions - **One Depth 0 request per write, or one listing at the end of the half for every write that lacked an ETag?** Recommended: Depth 0 per write. It keeps `put()` self-contained, and the conflict handling that follows a write already expects the ETag at once. --- Found by the efficiency and reliability audit of 2026-10-30 (finding PR6; index postulo/postulo#1108). Checked against `main` at `daee30b15` (2026-10-30).
tiagoagueda added this to the 0.6.0 milestone 2026-10-31 03:04:31 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo-dav#46
No description provided.