Replikering, del 2: hold kopien opdateret med hændelser

Hent ændringer med sekvensnummerfra og maxantal, anvend insert, update og delete som idempotente upserts, og gem cursoren sikkert.

5 min Niveau: Avanceret ✓ Kode testet

Når du har hentet et fuldt udtræk (se del 1), holder du kopien opdateret ved at hente hændelser: én række pr. ændring, nummereret med et stigende sekvensnummer. Du gemmer det sidste sekvensnummer, du har behandlet, og spørger næste gang efter alt, hvad der er kommet siden.

Sekvensnumre

Sekvensnummeret er globalt og stiger altid. Det er fælles for alle entiteter, så for én entitet er der huller i rækken. Det er normalt. Nummereringen fortsætter fra DAWA's. Vi leverer hændelserne selv; intet sendes videre til DAWA, som lukkede 1. oktober 2026. Vores hændelseslog går kun tilbage til sommeren 2026, og siden skiftet til Datafordeleren i oktober 2026 nummererer vi hændelserne selv. En ny kopi starter derfor altid med et fuldt udtræk, og du følger hændelser fra det sekvensnummer, du gemte før udtrækket, + 1. Fortsæt ikke fra en cursor, du har gemt fra DAWA.

/replikering/senestesekvensnummer giver det højeste nummer, der er tilgængeligt lige nu (det kræver ingen nøgle, og /replikering/senesteSekvensnummer virker også). Hændelser hentes med:

HTTP#request
GET /replikering/haendelser?entitet=adresse&sekvensnummerfra=207922000&sekvensnummertil=207940624&maxantal=1000
X-Api-Key: $DANADRESSE_API_KEY
ParameterBetydning
sekvensnummerfraFørste sekvensnummer, inklusive. Send din cursor + 1
sekvensnummertilSidste sekvensnummer, inklusive. Valgfri. Send værdien fra senestesekvensnummer
maxantalValgfri. Højst så mange hændelser i svaret, 1 til 50000. Uden maxantal får du hele intervallet fra sekvensnummerfra til sekvensnummertil

Svaret er et JSON-array sorteret efter sekvensnummer, ældste først. Uden maxantal indeholder det hele intervallet, og det kan blive stort efter en lang pause. Med maxantal henter du i sider: har svaret færre rækker end maxantal, har du fået det hele. Ellers spørger du igen fra sidste sekvensnummer + 1.

Prøv det

Hent det seneste sekvensnummer og tre adresse-hændelser fra lidt før:

Python#haendelser-peek
import os

import requests

BASE = "https://api.danadresse.dk"
H = {"X-Api-Key": os.environ["DANADRESSE_API_KEY"]}

seneste = requests.get(f"{BASE}/replikering/senestesekvensnummer", headers=H, timeout=10).json()
print("seneste sekvensnummer:", seneste["sekvensnummer"])

r = requests.get(
    f"{BASE}/replikering/haendelser",
    params={"entitet": "adresse", "sekvensnummerfra": seneste["sekvensnummer"] - 20000, "maxantal": 3},
    headers=H,
    timeout=30,
)
r.raise_for_status()
for h in r.json():
    print(h["sekvensnummer"], h["operation"], h["tidspunkt"], h["data"]["id"])

En hændelse ser sådan ud:

JSON#event
{
  "txid": null,
  "sekvensnummer": 207922077,
  "tidspunkt": "2026-10-15T08:27:59.776Z",
  "operation": "delete",
  "data": {
    "id": "ff5063fe-432a-4b9a-ae18-7fcb0fbed0f4",
    "status": 2,
    "adgangsadresseid": "547c02a1-b1ce-4fc6-a40e-612c77d75490",
    "etage": null,
    "doer": null,
    "oprettet": "2026-10-15T10:27:59.776",
    "aendret": "2026-10-15T10:27:59.776"
  }
}

Operationer og data

operationHvad du gør
insertUpsert rækken
updateUpsert rækken. Flet felterne ind i det, du har
deleteMarker rækken som nedlagt (status 2) eller henlagt (status 4), eller slet den

Et par ting, der er værd at vide:

  • Insert og update behandles ens. Et nyt objekt kommer som insert og en ændring som update. Ældre hændelser i loggen kan have update for et objekt, der var nyt, så brug en upsert til begge.
  • delete har data med. Hændelsen indeholder rækkens sidste tilstand, typisk med status 2 eller 4. Mange vælger at beholde rækken med den status, så et gammelt id stadig kan slås op.
  • data kan være delvis. En hændelse har ikke altid alle felter fra udtrækket. Flet felterne ind i den eksisterende række i stedet for at erstatte den.
  • Feltnavne. Hændelser for adgangsadresse bruger DAWA's navne med æ og ø (etrs89koordinat_øst, nøjagtighed, ændret), mens udtrækket bruger etrs89_x, noejagtighed og aendret. Ældre adresse-hændelser har dør og ændret. Omdøb dem, når du gemmer.
  • Koordinater. adgangsadresse-hændelser har kun ETRS89/UTM32-koordinater (srid 25832). Har du brug for længde- og breddegrad, så slå adressen op med /adgangsadresser/{id}, eller omregn selv. Se Koordinater og srid.
  • txid kan være null: hændelser hentet fra Datafordeleren har intet transaktions-id. Brug sekvensnummer som nøgle.
  • tidspunkt er UTC med millisekunder og Z.

Løbende hændelser findes for postnummer, navngivenvej, vejstykke, adgangsadresse og adresse. Andre entiteter, fx kommune, region og sogn, giver en tom liste. De ændrer sig sjældent, så hent dem igen med et nyt udtræk en gang om måneden.

Synkroniseringsscriptet

Scriptet fortsætter, hvor førsteudtrækket slap, og bruger samme SQLite-fil og replikering_cursor-tabel:

Python#synk
"""synk.py: hent hændelser siden sidste kørsel og opdater SQLite-kopien fra førsteudtrækket.

Kør fx hvert 15. minut fra cron. Sikker at køre igen efter et nedbrud: hændelser
anvendes som upserts, og cursoren gemmes i samme transaktion som ændringerne.
"""
import json
import os
import sqlite3
import time

import requests

BASE_URL = os.environ.get("DANADRESSE_BASE_URL", "https://api.danadresse.dk")
HEADERS = {"X-Api-Key": os.environ["DANADRESSE_API_KEY"]}
# Uden maxantal kommer hele intervallet fra..til i ét svar. Med maxantal deler du
# det op i sider, så én side aldrig bliver for stor (højst 50000).
MAXANTAL = 1000

# Samme rækkefølge og nøgler som i førsteudtrækket
ENTITETER = {
    "postnummer": ["nr"],
    "kommune": ["kode"],
    "navngivenvej": ["id"],
    "vejstykke": ["kommunekode", "vejkode"],
    "adgangsadresse": ["id"],
    "adresse": ["id"],
}

# Hændelser bruger enkelte andre feltnavne end udtrækket (DAWA-navne med æ/ø).
# Omdøb dem, så de lander i de samme nøgler i din tabel.
OMDOEB = {
    "dør": "doer",
    "ændret": "aendret",
    "ikrafttrædelsesdato": "ikrafttraedelsesdato",
    "nøjagtighed": "noejagtighed",
    "etrs89koordinat_øst": "etrs89_x",
    "etrs89koordinat_nord": "etrs89_y",
}


def get(path: str, **params) -> object:
    for forsoeg in range(3):
        r = requests.get(BASE_URL + path, params=params, headers=HEADERS, timeout=(5, 60))
        ra = r.headers.get("Retry-After", "")
        if r.status_code in (429, 503) and ra.isdigit() and forsoeg < 2:
            time.sleep(min(int(ra), 30))
            continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError("unreachable")


def cursor(db: sqlite3.Connection, entitet: str) -> int:
    row = db.execute("SELECT sekvensnummer FROM replikering_cursor WHERE entitet = ?", (entitet,)).fetchone()
    if row is None:
        raise SystemExit(f"{entitet}: ingen cursor. Kør førsteudtrækket først.")
    return row[0]


def gem_cursor(db: sqlite3.Connection, entitet: str, sekvensnummer: int) -> None:
    db.execute("UPDATE replikering_cursor SET sekvensnummer = ? WHERE entitet = ?", (sekvensnummer, entitet))


def anvend(db: sqlite3.Connection, entitet: str, noegle: list[str], h: dict) -> None:
    data = {OMDOEB.get(k, k): v for k, v in h["data"].items()}
    if h["operation"] == "delete":
        # Behold rækken som nedlagt (2) eller henlagt (4), så gamle id'er kan slås op
        data["status"] = data.get("status") if data.get("status") in (2, 4) else 4
    pk = "-".join(str(data[k]) for k in noegle)
    # insert og update er begge en upsert. data kan være delvis, så flet den ind
    # i den eksisterende række (json_patch) i stedet for at overskrive den.
    db.execute(
        f"INSERT INTO {entitet} (pk, status, data) VALUES (?, ?, ?) "
        "ON CONFLICT(pk) DO UPDATE SET status = coalesce(excluded.status, status), "
        "data = json_patch(data, excluded.data)",
        (pk, data.get("status"), json.dumps(data, ensure_ascii=False)),
    )


def synk_entitet(db: sqlite3.Connection, entitet: str, noegle: list[str], til: int) -> int:
    fra = cursor(db, entitet) + 1
    if fra > til:
        return 0  # allerede hentet; flyt aldrig cursoren baglæns
    antal = 0
    while fra <= til:
        haendelser = get("/replikering/haendelser", entitet=entitet,
                         sekvensnummerfra=fra, sekvensnummertil=til, maxantal=MAXANTAL)
        for h in haendelser:
            anvend(db, entitet, noegle, h)
        if haendelser:
            gem_cursor(db, entitet, haendelser[-1]["sekvensnummer"])
        db.commit()  # ændringer og cursor i én transaktion
        antal += len(haendelser)
        if len(haendelser) < MAXANTAL:
            break  # færre end maxantal: intervallet er hentet
        fra = haendelser[-1]["sekvensnummer"] + 1
    gem_cursor(db, entitet, til)  # alt til og med `til` er hentet
    db.commit()
    return antal


def main() -> None:
    db = sqlite3.connect("adresser.db")
    til = get("/replikering/senestesekvensnummer")["sekvensnummer"]
    for entitet, noegle in ENTITETER.items():
        print(f"{entitet}: {synk_entitet(db, entitet, noegle, til)} hændelser (til {til})")


if __name__ == "__main__":
    main()

Output fra en kørsel:

Text#synk-output
postnummer: 2 hændelser (til 207922555)
kommune: 0 hændelser (til 207922555)
navngivenvej: 1419 hændelser (til 207922555)
vejstykke: 1064 hændelser (til 207922555)
adgangsadresse: 2282 hændelser (til 207922555)
adresse: 428 hændelser (til 207922555)

Det, der gør scriptet sikkert at køre igen:

  1. til låses ved start. Alle entiteter synkroniseres til det samme sekvensnummer, så kopien er konsistent på tværs af tabeller.
  2. Cursoren gemmes i samme transaktion som ændringerne. Går processen ned midt i en side, rulles både ændringer og cursor tilbage, og næste kørsel starter samme sted.
  3. Upserts er idempotente. Får du den samme hændelse to gange, er resultatet det samme. Derfor gør det ikke noget, at hændelser fra før udtrækket blev færdigt, bliver anvendt igen.
  4. Retry på 429 og 503. Svarer API'et med Retry-After, venter scriptet og prøver igen.

Hvor ofte skal du spørge?

Vores hændelseslog opdateres omtrent én gang i timen. At køre scriptet hvert 15. minut er rigeligt. Det første kald, senestesekvensnummer, er billigt, og er tallet uændret siden sidst, er der ikke noget at hente.

Shell#cron
# crontab: synkronisér hvert 15. minut, aldrig to kørsler på én gang
*/15 * * * * cd /srv/adresser && flock -n synk.lock python synk.py >> synk.log 2>&1

flock -n sørger for, at en langsom kørsel ikke overlapper med den næste.

Vil du have besked, når der er nye hændelser, i stedet for at spørge? Se Webhooks. Webhooks og dette script passer sammen: webhooken fortæller, at der er noget nyt, og scriptet henter det. Se Webhooks: idempotens og fejl.

Fejl

StatuscodeÅrsag
400invalid_paramsekvensnummerfra, sekvensnummertil eller maxantal er ikke et heltal
404unknown_entityEntiteten findes ikke. details.supported lister de gyldige
401missing_api_keyNøglen mangler
429rate_limit_per_minuteVent Retry-After sekunder

Fejl har DAWA's form {"type", "title", "details", "code"}; styr efter code. Replikeringssvarene har i dag ikke X-RateLimit-*-headers. Følg dit forbrug på /dashboard/usage.

Relaterede guides

Markdown-kilde Fejl i guiden? Skriv til os API-reference (Swagger)

Prøv det med en gratis nøgle

2 000 kald/måned på gratisplanen. Intet kreditkort.

Opret konto →