Handling WFS Feature Locking and Lock Expiry in Python

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.

The Core Challenge: A Lock Is a Timer Nobody Watches

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.

The lock expired while the human was thinking A three-lane sequence. The editor requests features with a lock and a five-minute expiry; the server takes row locks in PostGIS and returns the features together with a lock identifier. The user then edits for six minutes, the lock expires server-side without any notification, and the transaction that follows is rejected with an exception saying the lock no longer exists. Editor WFS 2.0 PostGIS GetFeatureWithLock expiry=300 SELECT ... FOR UPDATE rows locked features + lockId user edits for 6 minutes lock expires at 300 s Transaction lockId=... releaseAction=ALL ows:Exception — lock not found

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.

Production-Ready Code

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)

Step-by-Step Walkthrough

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.

Six controls, and the two that decide failure behaviour A six-row grid of the WFS locking controls. GetFeatureWithLock combines fetch and lock; LockFeature locks data the client already holds. The lockAction parameter decides whether a partially available set is an error or a partial lock, and releaseAction decides whether committing frees every lock or only those on features that were actually changed. Operation or parameter What it does Use when GetFeatureWithLock fetch and lock in one call the client will edit what it read LockFeature lock without fetching the client already has the data lockAction=ALL fail unless every feature locks the edit is all-or-nothing lockAction=SOME lock whatever is free partial progress is acceptable releaseAction=ALL release the whole lock after commit the normal case releaseAction=SOME keep locks on untouched features a multi-step edit

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.

Verification

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.

Gotchas & Edge Cases

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.

Three lock disciplines, and the one real conflict A decision diamond for a failed locked transaction. An expiry shorter than the human edit time means the lock is gone before the commit; omitting the lock identifier from the transaction means the write is attempted unlocked; never releasing means abandoned locks accumulate and block other editors; and only once all three are handled is the failure a genuine conflict with another editor. A transaction failed with a lock error. Which discipline was missing? Renew or re-lock human edit time is unbounded Include it on the Transaction an unlocked write may still fail Set releaseAction=ALL abandoned locks block others Genuine contention another editor holds it expiry too short lockId not sent lock never released all correct

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.

Frequently Asked Questions

What expiry should I request?

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.

What happens if I omit lockId from the transaction?

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.

Can I renew a lock without committing?

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.

Should an automated pipeline lock at all?

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