27. Februar 2025

Leader Election Pattern mit der Web Locks API

Auch bei Web Applikationen gibt es Szenarien, in denen bestimmte Aktionen im Frontend exklusiv ausgeführt werden sollen. In der Informatik wird dazu eine Mutex verwendet. Moderne Browser verfügen glücklicherweise über eine API, mit der solche Locks sehr einfach implementiert werden können.

Software Development & Architecture
Landsgemeinde Glarus votes, 04.05.2014

Das Beispiel Token-Erneuerung

Ein Use Case, bei dem eine Aktion nur von einem Browser Tab durchgeführt werden soll, ist die Erneuerung eines Access Tokens im Frontend. Man terminiert dazu einen Timer auf den Zeitpunkt hin, wo das aktuelle Token abläuft und löst dann ein neues Token, wie vereinfacht in folgendem Beispiel dargestellt:

async function renew() {
  console.log("Renewing token...");
  await fetchNewToken();

  console.log("Idle (waiting for timer to fire)");
  setTimeout(renew, getTokenExpireIn());
}

setTimeout(renew, getTokenExpireIn());

Nun kann jedoch die Webapplikation in mehreren Tabs offen sein. In diesem Fall feuert in jedem Tab gleichzeitig der Timer und das Token würde mehrfach erneuert. Dieses Verhalten ist natürlich nicht erwünscht und führt unweigerlich zu Problemen. Ziel ist, die Erneuerung lediglich von einem Tab vornehmen zu lassen, folglich müssen wir also eine Leader Election implementieren.

Lock beantragen

Glücklicherweise bieten moderne Browser mit der Web Locks API genau das passende Werkzeug dafür an. Mit der Hilfe von navigator.locks.request, kann ein Lock angefordert werden, welcher über alle Tabs hinweg gültig ist. So könnte also die Erneuerung des Tokens mit einem Lock aussehen:

async function renew() {
  console.log("Waiting...");
  await navigator.locks.request("my_resource", async () => {
    console.log("Current leader!");

    console.log("Renewing token...");
    await fetchNewToken();
  });

  console.log("Idle (waiting for timer to fire)");
  setTimeout(renew, getTokenExpireIn());
}

setTimeout(renew, getTokenExpireIn());

Ein Lock besitzt einen eindeutigen Namen und definiert einen asynchronen Callback. Der Browser ruft den Callback auf, sofern der Lock vergeben werden kann. Weitere Lock-Requests werden blockiert, bis der vorherige Callback beendet wurde (resp. das zurückgegebene Promise resolved wurde). In unseren Fall warten also die anderen Tabs beim await auf Zeile 3 bis der «Leader» das Token erneuert hat, danach kommt ein weiterer Tab an die Reihe. Und dies ist schon die ganze Magie.

Optionen eines Locks

Ignorieren, wenn bereits gelockt

Aufmerksamen Leser:innen ist sicher aufgefallen, dass auch im vorherigen Beispiel das Token mehrfach erneuert würde, einfach sequentiell nacheinander. Um dies zu verhindern, könnte man jetzt eine Kondition einführen, damit nur der erste «Leader» das Token erneuert und die nachfolgenden Lock-Empfänger die Erneuerung nicht mehr durchführen:

await navigator.locks.request("my_resource", async () => {
  if (!tokenExpired()) {
    console.log("Token has already been renewed, do nothing");
    return;
  }

  console.log("Renewing token...");
  await fetchNewToken();
});

Eine bessere Alternative ist jedoch die Option, ⁣ mit{ ifAvailable: true } der beim zweiten Request (während der erste in Bearbeitung ist) der Callback mit dem Argument null statt einer Lock-Instanz aufgerufen wird. So können wir diesen zweiten Aufruf einfach ignorieren:

await navigator.locks.request(
  "my_resource",
  { ifAvailable: true },
  async (lock) => {
    if (!lock) {
      console.log("Other tab is already leader, do nothing");
      return;
    }
    // Renew token & re-schedule timer...
  },
);

Lock-Modi

Grundsätzlich unterstützt ein Lock zwei verschiedene Modi, welche mit der Option mode konfiguriert werden können. Dies erlaubt eine Implementation des Readers-Writer Lock Patterns. «Readers», welche nur lesend auf eine Resource zugreifen, können den Lock { mode: "shared" } beantragen und erhalten gleichzeitig Zugriff. «Writers» hingegen, welche auf die Ressource schreibend zugreifen, können den Lock mit { mode: "exclusive" } beantragen (dies ist der default Modus) damit alle anderen geblockt werden bis die Schreiboperation beendet ist.

Freigeben von Locks

Neben dem Resolven oder Rejecten des Promises, gibt es noch die Option signal, mit der ein Lock wieder freigegeben werden kann. Dies ermöglicht z.B. mit der Hilfe eines AbortControllers ein Timeout zu implementieren, um Dead Locks zu verhindern. Im folgenden Beispiel wird die Aktion nach 10 Sekunden abgebrochen, wenn das Promise nicht resolved:

const abortController = AbortController();

await navigator.locks.request(
  "my_resource",
  { signal: abortController.signal },
  async () => {
    const timeout = setTimeout(() => abortController.abort(), 10000);
    await doSomething();
    clearTimeout(timeout);
  },
);

Demo

Hier ein kleines Beispiel (Source Code) welches in mehreren Tabs geöffnet werden kann um den Mechanismus der Leader Election sichtbar zu demonstrieren:

Fazit

Es ist immer wieder eine schöne Überraschung welche Features die Browser APIs mittlerweile mitbringen und die Web Locks API ist ein gutes Beispiel dafür. Sie ist seit längerem von allen gängigen Browsern unterstützt und wartet unspektakulär auf den passenden Use Case, wo wir extrem froh sind darum.

Artikelbild: Landsgemeinde Glarus votes, Democracy International, CC BY-SA 2.0