Skip to content

Support conditional pending-dataset replaces with entity tags - #274

Draft
LorbusChris wants to merge 2 commits into
home-assistant-libs:mainfrom
LorbusChris:pending-dataset-if-match
Draft

Support conditional pending-dataset replaces with entity tags#274
LorbusChris wants to merge 2 commits into
home-assistant-libs:mainfrom
LorbusChris:pending-dataset-if-match

Conversation

@LorbusChris

@LorbusChris LorbusChris commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Draft until #269 and openthread/ot-br-posix#3553 are merged -- this branch is stacked on #269 (its commit shows here until it merges), and the border-router side of the API is openthread/ot-br-posix#3553.

set_pending_dataset_tlvs() refuses outright while a pending dataset is in flight (#269), because replacing one implicitly is never safe. But a caller that read the in-flight dataset, showed it to the user, and stamped a successor above it has a legitimate replace -- and still cannot say "replace exactly the one I read": another controller can slip a new pending dataset in between the read and the write.

This adds get_pending_dataset_tlvs_with_etag(), returning the dataset together with the entity tag a border router hands out for it, and an if_match keyword on set_pending_dataset_tlvs() that passes the tag back: the router then only replaces the dataset the tag stands for, checked atomically with the write (openthread/ot-br-posix#3553), and a 412 raises PendingDatasetConflictError telling the caller the dataset changed since it was read. This is the only way the library replaces a pending dataset. An older border router hands out no tag (the getter returns None for it) and would ignore the If-Match header, silently making the write unconditional -- which is why a caller must only offer if_match as an explicit, informed choice, and refuse it when the getter returned no tag.

Needed for the replace-pending follow-up to home-assistant/core#178291 (the otbr.migrate_network action gains an explicit replace_pending option that passes the tag back, so a migration only supersedes the in-flight dataset the user actually saw).

AI use: Written with AI assistance -- the commit carries an Assisted-By trailer. I've reviewed and understand all of it, and will be answering questions myself.

@LorbusChris
LorbusChris force-pushed the pending-dataset-if-match branch 2 times, most recently from 855f95f to 4287a2b Compare August 28, 2026 23:47
Add set_pending_dataset_tlvs(), the pending-endpoint twin of
set_active_dataset_tlvs(), so a controller can hand a border router a
new operational dataset as a raw TLV pending set.

The TLV parser gains the write-side counterparts callers need to build
such a dataset: Timestamp.from_values(), with range checks for the
48-bit seconds and 15-bit ticks fields, and
DelayTimer.from_milliseconds(). PENDINGTIMESTAMP and DELAYTIMER now
decode to typed items, matching ACTIVETIMESTAMP.

Also raise TLVError instead of leaking struct.error on a malformed
timestamp or delay timer TLV, and log unknown TLVs by type and length only -- the
value may hold network credentials.

Like the channel change, the write is refused while a pending dataset
is already in place. A not-newer one would be silently ignored by the
mesh, and superseding is never safe to do implicitly: the replacement
races the delay timer on every device that already holds the old
dataset, so a late replacement can split the mesh, and it would also
silently undo whatever the in-flight dataset was doing. A caller that
finds the write refused should surface that, not retry.

The check runs locally first, and again on the border router: unless
allow_replace is set the request carries "If-None-Match: *", which a
border router with openthread/ot-br-posix#3552 evaluates atomically
with the write, closing the race the local check leaves open. Older
border routers ignore the header and keep relying on the local check.

Both refusals raise PendingDatasetConflictError rather than a plain
OTBRError, so a caller can tell them apart from a transport or protocol
failure without matching on the message: nothing was written, and the
state that made the write wrong is there to be read back.

Assisted-By: Claude Fable 5
set_pending_dataset_tlvs() refuses whenever a pending dataset is in
place, and deliberately offers no blanket override: superseding races
the delay timer on every device holding the old dataset and silently
undoes whatever that dataset was doing. What a caller may reasonably
want is narrower: "replace exactly the dataset I read", as an explicit,
informed action.

Add get_pending_dataset_tlvs_with_etag(), returning the dataset
together with the entity tag a border router hands out for it, and an
if_match keyword on set_pending_dataset_tlvs() that passes the tag
back: the router then only replaces the dataset the tag stands for,
checked atomically with the write (openthread/ot-br-posix#3553). An
older border router hands out no tag and would ignore the header,
making the replace there unconditional, which is why callers must only
expose this as an explicit choice, never as a default.

A conditional replace that the router refuses raises the same
PendingDatasetConflictError the guarded write already raises, with a
message naming the other reason: the dataset changed since it was read.

Assisted-By: Claude Fable 5
@LorbusChris
LorbusChris force-pushed the pending-dataset-if-match branch from 4287a2b to aebad9b Compare September 1, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant