Detecting Orphaned Metadata Records in a Catalog

Reconcile the catalogue against the data store in both directions on a schedule: records whose dataset no longer exists are orphans, datasets with no record are unlisted, and the two failures need different owners and different responses.

Catalogues drift from the things they describe because the two are updated by different processes. A dataset is deleted by a retention job, moved by a migration, or renamed during a reorganisation, and nothing tells the catalogue. The record continues to be returned by searches, harvested by partners and counted in coverage figures, describing something that is not there. This guide sits under automated broken link and reference detection, within CI/CD Validation & Policy Enforcement for Spatial Data.

Four states a dataset and its record can be inThe four combinations of record present or absent and dataset present or absent, with the finding and owner for each.Dataset presentDataset absentRecord presenthealthyorphaned recordRecord absentunlisted datasetnothing to do
Two of these four states are the normal ones. The other two need different people to act.

Automated Python Implementation

The reconciler compares two identifier sets and classifies the difference. Its only real subtlety is deciding what counts as the identity of a dataset, which is where naive implementations go wrong.

#!/usr/bin/env python3
"""Reconcile a metadata catalogue against the datasets it describes."""
import json
import pathlib
from dataclasses import dataclass, field


@dataclass
class Reconciliation:
    healthy: set = field(default_factory=set)
    orphaned: dict = field(default_factory=dict)   # record id -> last known location
    unlisted: dict = field(default_factory=dict)   # dataset id -> where it was found
    ambiguous: dict = field(default_factory=dict)  # id -> why it could not be resolved


def catalogue_identities(records):
    """Identity comes from a recorded identifier, never from a file path."""
    identities, ambiguous = {}, {}
    for record in records:
        identifier = record.get("dataset_identifier")
        if not identifier:
            ambiguous[record["id"]] = "record carries no dataset identifier"
            continue
        if identifier in identities:
            ambiguous[identifier] = "two records claim the same dataset identifier"
            continue
        identities[identifier] = record
    return identities, ambiguous


def store_identities(root, read_identifier):
    """Read the identifier the dataset carries, not the name it happens to have."""
    identities, ambiguous = {}, {}
    for path in sorted(pathlib.Path(root).rglob("*")):
        if path.suffix.lower() not in {".gpkg", ".tif", ".geojson"}:
            continue
        try:
            identifier = read_identifier(path)
        except Exception as exc:
            ambiguous[str(path)] = f"unreadable: {exc}"
            continue
        if not identifier:
            ambiguous[str(path)] = "dataset carries no identifier"
            continue
        identities[identifier] = str(path)
    return identities, ambiguous


def reconcile(records, root, read_identifier):
    catalogue, cat_bad = catalogue_identities(records)
    store, store_bad = store_identities(root, read_identifier)

    result = Reconciliation()
    result.ambiguous = {**cat_bad, **store_bad}
    for identifier, record in catalogue.items():
        if identifier in store:
            result.healthy.add(identifier)
        else:
            result.orphaned[identifier] = record.get("last_known_location")
    for identifier, path in store.items():
        if identifier not in catalogue:
            result.unlisted[identifier] = path
    return result


def report(result):
    return {
        "healthy": len(result.healthy),
        "orphaned": result.orphaned,
        "unlisted": result.unlisted,
        "ambiguous": result.ambiguous,
        "coverage": len(result.healthy) / max(
            1, len(result.healthy) + len(result.unlisted)),
    }

The design decision that matters is that identity comes from an identifier the dataset itself carries, not from its path or filename. Reconciling on paths produces a flood of false orphans on the day a directory is reorganised, and the response to a flood of false orphans is to stop running the reconciler. An identifier written into the data — the GeoPackage metadata table, a raster tag, a sidecar — survives moves and renames, which is the property the whole exercise depends on.

Reconciling in both directionsIdentifiers are read from the catalogue and from the store, the two sets are compared, and differences are classified as orphaned or unlisted.Catalogue identifiersfrom the recordscompareStore identifiersread from the dataclassifySet differenceboth directionsreportOrphans + unlistedwith coverage
One comparison, two findings, two owners.

Validation and Pipeline Integration

def test_moved_dataset_is_not_reported_as_orphaned():
    records = [{"id": "r1", "dataset_identifier": "urn:uuid:abc"}]
    store = {"urn:uuid:abc": "/new/location/roads.gpkg"}
    result = reconcile(records, "/new/location", lambda _p: "urn:uuid:abc")
    assert not result.orphaned


def test_duplicate_identifier_is_ambiguous_not_healthy():
    records = [{"id": "r1", "dataset_identifier": "urn:uuid:abc"},
               {"id": "r2", "dataset_identifier": "urn:uuid:abc"}]
    _, ambiguous = catalogue_identities(records)
    assert "urn:uuid:abc" in ambiguous


def test_unlisted_dataset_is_reported_separately():
    result = reconcile([], "/data", lambda _p: "urn:uuid:def")
    assert result.unlisted and not result.orphaned

Run the reconciliation on a schedule and route the two findings differently. An orphaned record is a catalogue problem, owned by whoever maintains the catalogue, and the resolution is usually a tombstone rather than a deletion. An unlisted dataset is a publishing problem, owned by the team that produced it, and the resolution is a record. Sending both to the same queue means one of them is always somebody else’s and neither gets done.

What to do with an orphaned recordThree tests decide whether an orphaned record should be tombstoned, restored, or corrected.Was the dataset deliberatelyretired?Publish a tombstoneidentifier, deleted flag, dateyesnoDid it move without the recordfollowing?Correct the recordthe identifier already matchesyesnoInvestigatean unplanned deletion is an incident
Deleting the record outright is the option that leaves harvesters holding it forever.

Tombstones, and Why Deletion Is Not Enough

Removing an orphaned record from the catalogue feels like the obvious fix and leaves the problem in place for everyone downstream. Harvesters synchronise by fetching what exists; absence is not a signal they receive. A record deleted from your catalogue remains in every partner’s copy indefinitely, and those copies are what users search.

A tombstone is a record that asserts the deletion: the same identifier, a deleted flag, the date, and ideally a reason and a successor identifier where one exists. It is a few hundred bytes, it propagates through the same harvest mechanism as any other record, and it is the only way a downstream copy can learn that a dataset is gone.

Two properties make tombstones work in practice. They must keep the original identifier — a tombstone with a new identifier is a new record describing nothing, and the original remains live downstream. And they must persist: a tombstone deleted after six months because the catalogue looks untidy re-creates the problem for any harvester that had not synchronised in that window. Tombstones are cheap enough to keep indefinitely and the alternative is unbounded downstream inaccuracy.

Where a dataset has been replaced rather than withdrawn, naming the successor turns the tombstone from a dead end into a redirect. A user who finds the retired record learns both that it is gone and what to use instead, which is more than most catalogues manage for datasets that are still live.

When the Store Cannot Be Enumerated

The reconciliation above assumes both sides can be listed. Sometimes the data store cannot be — it is a partner’s service, an object store the catalogue team has no credentials for, or a database whose enumeration would be prohibitively expensive.

One-directional reconciliation still works and is worth running. Checking that each catalogued identifier resolves to something is a per-record probe rather than a full listing, and it finds orphans. What it cannot find is unlisted datasets, so the coverage figure becomes unavailable and should be reported as unknown rather than as 100 percent — the failure mode here is a dashboard that shows perfect coverage because it only ever looked at what it already knew about.

Where the store is a partner’s, the reconciliation becomes a request rather than a scan: an identifier list exchanged periodically, compared locally. That is slower and less frequent, and it is the only mechanism available. Recording when the last exchange happened, and treating a stale exchange as reduced confidence rather than as a pass, keeps the reported state honest.

Long-Term Compliance Best Practices

  • Identify datasets by an identifier they carry, not by path. Reorganisations are routine; false orphans destroy trust in the reconciler.
  • Report unlisted datasets as loudly as orphans. An unlisted dataset is undiscoverable, which is the failure the catalogue exists to prevent.
  • Track coverage over time. The absolute number matters less than the direction.
  • Treat a duplicate identifier as ambiguous, not healthy. Two records claiming one dataset is a defect that reconciliation is well placed to catch.
  • Never delete without a tombstone. Absence does not propagate; assertions do.