Skip to content

Latest commit

 

History

History
213 lines (159 loc) · 12.1 KB

File metadata and controls

213 lines (159 loc) · 12.1 KB

Limitations

Where the prototype departs from its design, or stops short of it. Written down rather than discovered.

1. Write validation sees the candidate record, not the whole graph

SHACL validation runs on the record being written, before it is committed. Constraints that would need the rest of the store — sh:class on a referenced node, say — are therefore not evaluated. That is the price of validating before committing rather than after.

Validation itself is real SHACL: shapes/tar-shapes.ttl is enforced by a SHACL engine, and editing that file changes what the API accepts with no Rust change. Severity decides: sh:Violation blocks, sh:Warning never does, and TAR_SHACL_VALIDATE_WRITES=false downgrades violations to warnings.

Two rules that do need the rest of the graph live in Rust for exactly this reason, and report through the same sh:ValidationReport: whether an artifact type is a term the registry holds, and whether a deployment of non-deployable software may carry an endpoint. Neither honours TAR_SHACL_VALIDATE_WRITES — a half-described record is a trade an operator can make, an unlookuppable type is not.

The first of those is exactly sh:class, and it was measured rather than assumed. Injecting the bundles' class assertions into the candidate data graph so the engine can see them takes validation from 1.9 ms to 9.8 ms per write, plus 2.8 ms to read them back, against the 52 µs the targeted lookup costs — roughly 2 ms a write against 12.6 ms.

The time is the smaller objection. Three things a shape cannot do at all settled it: sh:message is fixed text and could not name the offending IRI or the way out; TAR_SHACL_VALIDATE_WRITES=false would switch the rule off; and the allowance for a type cached from a peer is about which named graph a concept sits in, which the engine's single data graph cannot express.

2. Distributions and capabilities are IRIs, not blank nodes

The design shows blank nodes. They are minted as IRIs instead, which makes them addressable and citable. A record still reads as one document, because describe returns them with their parent.

3. Two denormalised links

tar:instanceOf and tar:atInstance sit alongside the authoritative prov:qualifiedAssociation. Every list and count query would otherwise be a two-hop join through a reified node.

4. Repository liveness metrics are not implemented

Repository sync is — a record can keep named fields in step with its source repository. What is missing is the liveness signal the design asked for: stars, forks, last commit. The UI degrades by omitting those cells rather than rendering zeros.

5. Human sign-in has no token refresh — closed

Sign-in itself is verified against a live identity provider: a browser was driven through authorisation code + PKCE, and the curator and admin roles were confirmed to decide what a signed-in person may do — POST /api/v1/software succeeds for a curator, /api/v1/peers is 403 for a curator and 200 for an admin.

The gap was refresh: the access token was used until it expired with no silent renewal, so a long editing session ended in a 401 and a re-sign-in with the form's contents lost. The registry now renews twice over — from a timer set from the token's own exp claim, ahead of expiry, and reactively when a request comes back 401 for the case the timer missed (a laptop that slept through it). Concurrent requests that all expire together share one renewal rather than each spending the refresh token, which matters once a provider rotates it: the first use would otherwise invalidate it for the rest.

The refresh token itself stays exactly where the design note said it should not go: in memory only, never sessionStorage, localStorage, or a cookie. A closed tab or a reload ends the renewable session — the access token in sessionStorage keeps working until it expires, then sign-in is needed again — which is the trade this file has always made, kept rather than loosened to get renewal.

A registry API token has no refresh token to renew with, so nothing changes for it: a 401 still means sign in again, exactly as before.

6. Federated search is not deduplicated

Fan-out is live, and results are not deduplicated across peers beyond the origin chip. Two peers that both cache a stub of the same third-party record produce two rows.

7. Peer resolution caches more than it needs to

The resolver fetches a whole Turtle document into the peer graph rather than extracting a minimal stub, so a verbose peer can cache considerably more than the type, title, publisher and home registry the design called for.

8. Keyset pagination orders by IRI string

Which is time-ordered within one registry, because identifiers are UUIDv7. Across origins it interleaves imperfectly.

9. No lineage graph visualisation

Deferred. GET /api/v1/graph and GET /api/v1/artifacts/{id}/lineage already return exactly what a graph view would need.

10. A shapes or vocabulary change can strand an existing record

A write is judged on the whole record it asserts, and a PATCH carries the fields the caller did not name — so a record citing a term the registry has since retired is refused on an edit to some other field entirely.

The boot log names every such record and the term, once, rather than deleting a value nobody asked it to delete. Replacing or clearing the named term fixes the record permanently. Nothing shipped is affected; a long-lived store might be.

11. A type resolved from a peer is accepted but not offered

It is a term this registry holds, so citing it is fine. It carries none of this registry's own classes, so the picker does not list it.

That is the conservative half-answer. Which registry owns a term, and whether adopting a peer's implies agreeing with it, is a federation question this prototype does not settle. The picker's adopt flow is how to make such a term first-class here.

12. Observed capability is not reconciled with declared capability

The software page shows what was declared. What a deployment has actually produced is one SPARQL query away, and the two can disagree. Surfacing the disagreement should wait until there is enough run data for it to mean something.

13. A residual DNS-rebinding race on webhook targets — closed

A subscription's webhook URL is checked against private and loopback addresses at registration and re-resolved at send time. The send-time check used to return only a verdict, leaving the HTTP client to resolve the name a second time — so a record with a short TTL could answer the check with a public address and the connection with 169.254.169.254.

The check now returns the addresses it approved and the delivery is pinned to them, so there is no second lookup to win. TLS still verifies the certificate against the hostname: pinning replaces DNS, not identity.

14. Subscriptions have no scope of their own

Managing a subscription reuses the rule that governs token management — admin, curator, or the credential of the owning deployment. There is no subscribe:* scope, so a credential cannot be issued that may subscribe and nothing else.

15. The external SPARQL backend blocks a worker thread per store call — closed for the request path

GraphStore is a synchronous trait — it predates the second backend, and every read in the registry is a plain function call because the store used to be in-process. Against TAR_SPARQL_ENDPOINT, calling one of its methods directly from an async handler occupied that handler's Tokio worker thread for the whole HTTP round trip: on a multi-threaded runtime with more concurrent requests than worker threads, one slow remote query delayed every other request the same worker was about to advance.

Making the trait itself async fn — the fix this entry used to name — turned out to be far larger than it reads: not 119 call sites, but recolouring most of src/domain, most of src/api, and src/auth/jwt, converting every synchronous store-layer unit test to #[tokio::test], and rewriting iterator patterns that call domain functions into stream combinators. The change actually made is the one Tokio's own documentation recommends for exactly this shape of problem: every request-path call site — all ~90 API handlers across 18 files, and the three OIDC credential-binding lookups in auth::jwt that run on every bearer-token request before any handler is reached — passes its synchronous, store-touching work to [error::blocking], which runs it on Tokio's dedicated blocking thread pool and gives the worker thread back to the scheduler for the round trip's duration. Ctx moved from borrowing &AppState to owning an Arc<AppState> clone so it can cross that boundary. Nothing in src/domain or src/store changed: the same synchronous functions run, just on a different thread.

Two call sites were deliberately left as they were, both flagged in code comments: the per-artifact and per-was_derived_from-parent existence checks inside advertise()'s main loop, and the equivalent one in the OpenLineage adapter's map_dataset. Both interleave a synchronous exists() check with state.ops.* calls that are already async, inside a loop that decides its own control flow per iteration — cleanly separating the two would mean restructuring the loop into two passes, which is a larger and more error-prone change than the check's actual cost justifies: it is a single fast existence lookup, not a scan.

Also unwrapped, and deliberately: seed.rs, bundles.rs::sync, and the peer-resolver and health-check background loops. Those run once at boot or pace themselves on their own timer, never competing with a flood of concurrent user requests for the same worker pool, which is the specific failure this fix closes.

Embedded Oxigraph, the default, is unaffected either way: those calls never leave the process, so moving them to a different thread pool costs a small fixed dispatch overhead and buys nothing. The behavioural difference only shows up against a remote endpoint under real concurrent load.

16. Atomicity on an external endpoint is the server's promise, not ours

A registry write is built into a single SPARQL Update request so that its deletions and insertions are one unit. Fuseki, GraphDB, Virtuoso and Oxigraph's own server execute a request in one transaction, which is what makes this equivalent to the embedded backend's transaction.

SPARQL 1.1 does not require that. Against a server that processes the operations of a request independently, a write is atomic only per operation, and the registry has no way to tell over HTTP which kind of server it is talking to. It does not attempt to detect it and does not claim the guarantee it cannot verify.

17. Two rough edges in the external backend

Neither affects the embedded default.

A subject deletion follows the ownership closure four levels deep, where the embedded store's walk is unbounded. A record's sub-resources — its distributions, its checksums — are removed with it so that replacing a record does not orphan them, and doing that inside one atomic update request means a fixed-depth pattern rather than a walk that can look at what it found. The deepest nesting the registry writes is two levels, so this is headroom; a sub-resource nested deeper than four would be orphaned against an external endpoint and removed against the embedded one.

/admin/dump and tar dump materialise the whole graph in memory on the external backend, because there is no streaming path from a SELECT result to the response body. Embedded Oxigraph streams. For a catalogue-sized registry this is fine and for a very large external dataset it is not; back that up with the store's own tools instead.

18. tar dump --graph loses the graph name, on either backend

tar dump with no argument writes N-Quads and restores faithfully. tar dump --graph <g> writes N-Triples — which is what /admin/dump?graph= and peer stub exchange want — so restoring that file with tar restore puts its triples in the default graph, where nothing looks for them. Pre-existing behaviour, unchanged by the second backend, and worth knowing before using a single-graph dump as a backup.