GetFeatureWithLock returns features and a lockId in one call; the transaction that follows must carry that lockId or it is an unlocked write. The expiry you request is measured in seconds from the server’s clock and elapses silently — no callback, no warning — so the practical rules are: pick an expiry longer than a distracted human, renew if the edit runs long, and always release in a finally block.
Locking exists because the edit-review-commit cycle in a spatial editor takes minutes, and two editors working the same parcel boundary will otherwise silently overwrite each other. The WFS mechanism is straightforward — lock, edit, commit with the lock identifier, release — and its one hazard is that the middle step has no upper bound and the lock does.
When the timer elapses, the server releases the rows and forgets the identifier. The client finds out at commit time, by which point the user has done several minutes of work whose only copy is in a browser. Nothing in the protocol pushes a warning to the client, so the countdown has to be tracked client-side or the expiry has to be generous enough that it never matters.
from __future__ import annotations
import time
from contextlib import contextmanager
from dataclasses import dataclass
from xml.etree import ElementTree as ET
import requests
WFS = "http://www.opengis.net/wfs/2.0"
FES = "http://www.opengis.net/fes/2.0"
for prefix, uri in (("wfs", WFS), ("fes", FES)):
ET.register_namespace(prefix, uri)
class LockLost(RuntimeError):
"""The lock expired or was never granted for every requested feature."""
@dataclass
class Lock:
lock_id: str
expires_at: float
feature_ids: tuple[str, ...]
@property
def seconds_left(self) -> float:
return max(0.0, self.expires_at - time.monotonic())
def assert_live(self, margin: float = 20.0) -> None:
"""Fail before sending rather than after the server refuses."""
if self.seconds_left < margin:
raise LockLost(f"lock {self.lock_id} has {self.seconds_left:.0f}s left")
def _post(url: str, root: ET.Element, timeout: int) -> ET.Element:
resp = requests.post(url, timeout=timeout,
data=ET.tostring(root, encoding="utf-8"),
headers={"Content-Type": "text/xml"})
resp.raise_for_status()
parsed = ET.fromstring(resp.content)
for el in parsed.iter():
if el.tag.rsplit("}", 1)[-1] == "Exception":
raise LockLost(" ".join((t.text or "").strip() for t in el))
return parsed
def get_feature_with_lock(url: str, type_name: str, filter_el: ET.Element,
expiry: int = 900, timeout: int = 60) -> tuple[Lock, ET.Element]:
"""Fetch features and lock them atomically.
lockAction=ALL makes a partially available set an error rather than a
partial lock — for an editing session that is what you want, because a
half-locked edit commits half the user's work.
"""
root = ET.Element(f"{{{WFS}}}GetFeatureWithLock", service="WFS",
version="2.0.0", expiry=str(expiry), lockAction="ALL")
query = ET.SubElement(root, f"{{{WFS}}}Query", typeNames=type_name)
query.append(filter_el)
requested_at = time.monotonic()
parsed = _post(url, root, timeout)
lock_id = parsed.get("lockId")
if not lock_id:
raise LockLost("server returned no lockId — locking may be unsupported")
ids = tuple(
el.get(f"{{http://www.opengis.net/gml/3.2}}id")
for el in parsed.iter()
if el.get(f"{{http://www.opengis.net/gml/3.2}}id"))
return Lock(lock_id, requested_at + expiry, ids), parsed
def commit(url: str, lock: Lock, operations: list[ET.Element],
release: str = "ALL", timeout: int = 120) -> ET.Element:
"""Send a transaction under an existing lock."""
lock.assert_live()
tx = ET.Element(f"{{{WFS}}}Transaction", service="WFS", version="2.0.0",
lockId=lock.lock_id, releaseAction=release)
for op in operations:
tx.append(op)
return _post(url, tx, timeout)
def release(url: str, lock: Lock, timeout: int = 30) -> None:
"""Release without committing — an empty transaction with releaseAction=ALL."""
tx = ET.Element(f"{{{WFS}}}Transaction", service="WFS", version="2.0.0",
lockId=lock.lock_id, releaseAction="ALL")
try:
_post(url, tx, timeout)
except LockLost:
pass # already expired; nothing to release
@contextmanager
def locked(url: str, type_name: str, filter_el: ET.Element, expiry: int = 900):
"""Acquire a lock for the duration of a block, releasing it whatever happens.
Without the finally, an exception in the edit path leaves rows locked
until expiry — which on a long expiry means other editors are blocked
for a quarter of an hour by a traceback.
"""
lock, features = get_feature_with_lock(url, type_name, filter_el, expiry)
try:
yield lock, features
finally:
release(url, lock)
lockAction="ALL" is the right default for an editing session. With SOME, a server locks whichever of the requested features are free and returns the rest unlocked, with the same response shape. The client then edits a set it only partly owns, and the transaction commits partly. ALL turns that into an immediate, honest failure.
The expiry clock starts before the response arrives. Lock records time.monotonic() at request time rather than at response time, which deliberately under-estimates the remaining window by the round-trip duration. Erring in that direction means assert_live fires slightly early rather than slightly late, and slightly late is the case that loses work.
assert_live with a margin fails fast. Checking twenty seconds before the nominal expiry catches the case where a transaction would be sent just as the lock evaporates — network latency plus server clock skew makes the boundary fuzzy, and the margin absorbs it. The caller gets an exception naming the lock instead of a protocol error naming nothing useful.
The context manager is where abandoned locks die. An unhandled exception between lock and commit leaves rows locked until expiry. On a fifteen-minute expiry that is fifteen minutes of other editors seeing failures caused by a traceback in a different process. finally: release(...) costs one round trip and removes the whole class of problem.
Confirm the lock is granted, that a second client is refused, and that release works:
predicate = _filter_by_ids(["parcels.4471", "parcels.4472"])
with locked(url, "cadastre:parcels", predicate, expiry=900) as (lock, features):
print("locked", lock.feature_ids, f"{lock.seconds_left:.0f}s left")
# a second client must not get these
try:
get_feature_with_lock(url, "cadastre:parcels", predicate, expiry=60)
except LockLost as exc:
print("second client refused:", exc)
commit(url, lock, [update_op])
locked ('parcels.4471', 'parcels.4472') 899s left
second client refused: Feature(s) parcels.4471 are already locked
The second acquisition failing is the property worth testing: a locking implementation that grants both is not providing the exclusion the workflow depends on.
Not every WFS implements locking. It is an optional conformance class, and a server that does not support it may return features with no lockId rather than an error. The explicit check for a missing identifier turns “silently unlocked” into a failure at the point of acquisition rather than a lost update days later.
Expiry is server-side and may be capped. A request for a three-hour lock is commonly clamped to the server’s configured maximum without comment. If long edits are genuinely required, the workable pattern is a shorter lock renewed periodically from the client rather than one long one, since the renewal also proves the session is still alive.
releaseAction="SOME" keeps locks on untouched features. That is occasionally what a multi-step editing workflow wants, and it is a very effective way to leak locks if the later steps never happen. Default to ALL and reach for SOME deliberately.
Locks and long transactions interact badly. Holding a lock across a batch of thousands of features means every other editor waits for the whole batch. The chunking discipline in WFS Transactional Operations Deep Dive applies here with extra force: lock the chunk, commit it, release, then take the next.
Longer than the slowest realistic human edit, which in practice means minutes rather than seconds. Fifteen minutes is a reasonable default for an interactive editing session. For an automated batch, use the shortest value that comfortably covers the batch, because the cost of an abandoned lock is borne by every other client.
The write is attempted unlocked. On some servers it succeeds, which defeats the point of having locked; on others it fails because the rows are still locked by your own earlier request. Both outcomes are confusing, and both are avoided by deriving the transaction from the lock object rather than passing the identifier separately.
Not through a dedicated operation — WFS 2.0 has no renew verb. The practical equivalent is a LockFeature call over the same features, which returns a fresh identifier and a fresh expiry. Track the new identifier, because the old one becomes meaningless.
Only if a human editor might be working the same features concurrently. A pipeline that owns its data exclusively pays a round trip per batch for exclusion it does not need. Where humans and automation share a layer, locking is the only mechanism the protocol offers, and the pipeline should take the shortest lock that covers its write.
Back to WFS Transactional Operations Deep Dive
Related