apache / apache/jena

Proposal: multi-request transaction support for Fuseki, built on TDB2

Open
#4,123 11 comments 0 reactions 1 assignee Claimed by @afs View on GitHub
enhancement
Dominant language
Java
Stars
1.4k
Forks
712
Avg merge
15h 41m
Merged PRs (30d)
53

Description

### Version

latest

### Feature

Proposal: multi-request transaction support for Fuseki, built on TDB2

I've hoped for this for a long time, now it might be within grasp.
Yes, AI wrote this. I haven't dug into the Jena/Fuseki code, but I did review the proposal (and made minor edits).

## Motivation

SPARQL 1.1/1.2 Protocol only defines atomic, single-shot query and update operations, there's no notion of a transaction spanning more than one HTTP request. This has come up repeatedly outside this project too: [w3c/sparql-dev#83](https://github.com/w3c/sparql-dev/issues/83) is a long-running discussion of exactly this gap, and @afs already sketched an approach there:

> An alternative is an addition query string parameter and use the usual endpoint for query/update etc. Given that security may apply on some endpoints based on URL (e.g. different URLs for query and update), using the same endpoints for operations with transactions and single operations would be helpful. It is also helpful to client libraries as the sequence "begin"-any existing code-"commit" works. `/transactions/` is for transaction control.

This issue proposes building that out for Fuseki, on top of TDB2's *existing* internal ACID transactions (`Transactional`/`TxnType`, Serializable isolation). The TDB2 storage engine already does the hard part, what's missing is a way for a client to hold one of those transactions open across several HTTP requests.

Other triple stores have shipped support: RDF4J's REST API has a dedicated transaction resource, Blazegraph had a `Transaction-Location` header proposal over its REST Transaction API), Allegrograph supports transactions with session ports.

## Proposed API

Following @afs's sketch:

- **`/ds/query` and `/ds/update` stay exactly as they are** for ordinary, non-transactional requests — zero behavior change for existing clients.
- **New control endpoint, `/ds/transactions`** (a new `Operation`, dispatched the same way `Query`/`Update`/`GSP_*` are today via `OperationRegistry`/`DataService`):
- `POST /ds/transactions?type={read|write|read-promote|read-committed-promote}` — begins a transaction (`TxnType` maps directly onto the existing enum in `org.apache.jena.query.TxnType`). Response: `Location: /ds/transactions/{txnId}`, `txnId` a server-generated opaque token (UUID).
- `POST /ds/transactions/{txnId}?action=commit` — commit.
- `POST /ds/transactions/{txnId}?action=abort` — abort/rollback.
- `GET /ds/transactions/{txnId}` — status (active/idle-time remaining), mainly for diagnostics.
- **A transaction-scoped request** is just an ordinary `/ds/query` or `/ds/update` call carrying an `SPARQL-Transaction: {txnId}` request header. No other change to the query/update request shape.

## Mapping onto the existing Jena/Fuseki internals

(Filed against current `main`; class/line references may drift.)

- `HttpAction` (`jena-fuseki2/jena-fuseki-core/.../servlets/HttpAction.java`) currently assumes one begin/end pair per request, on one thread (`isInActionTxn`, `activeDSG` are set up in `begin(TxnType)` and torn down in `endInternal()` within the same servlet call — see `SPARQLQueryProcessor.execute()` and `SPARQL_Update.execute()`). A held, cross-request transaction can't just reuse this path unmodified, since a transaction begun on one request's thread must be resumed on a *different* request's thread.
- TDB2 already has the primitive this needs: `DatasetGraphTDB`'s `TransactionalSystem` (`org.apache.jena.dboe.transaction.txn.TransactionalBase`, in `jena-dboe-transaction`) implements
```java
TransactionCoordinatorState detach();
void attach(TransactionCoordinatorState coordinatorState);
```
which suspends a transaction off its current thread and resumes it on another. It's just not exposed above `DatasetGraphTDB` today — reaching it means unwrapping to the TDB2-specific type. Proposal: expose a narrow SPI (e.g. `DetachableTransactional` with default no-op `detach()`/`attach()`, implemented for real by TDB2's `DatasetGraphTDB`) so Fuseki can ask a `DatasetGraph` "can you hand a transaction across threads?" generically, and reject/501 the new endpoint for datasets that answer no.
- **TDB1 has no equivalent.** `DatasetGraphTransaction` binds via a plain `ThreadLocal` with no suspend/resume API. Same for in-memory and other non-TDB2 `DatasetGraph`s. **v1 of this proposal is TDB2-only**; other backends would report "not detachable" and a `POST /ds/transactions` against them would fail cleanly rather than half-working.
- A new server-side registry (per `DataService`, alongside its existing `activeTxn`/`totalTxn` counters in `DataService.java`) holds `{txnId -> HeldTransaction}`, where `HeldTransaction` wraps the detached `TransactionCoordinatorState`, the `TxnType`, and a last-touched timestamp. On a query/update request carrying `SPARQL-Transaction`, Fuseki looks up the entry, `attach()`s it for the duration of that one request, executes normally, then `detach()`s again in `finally` — explicit `commit()`/`abort()` only happen via the control endpoint.
- **Abandoned-transaction handling is not optional.** A client that opens a write transaction and disappears would otherwise block every other writer indefinitely, and a leaked read transaction on the fallback lock path (`TransactionalLock`/`LockMRSW`, for non-TDB2 datasets, though those are out of scope here) can walk into the `ReentrantReadWriteLock` "maximum lock count exceeded" failure Jena has hit before (e.g. #1499, #2584). Proposal: an idle-timeout reaper thread per dataset that force-aborts held transactions past a configurable deadline, plus a configurable cap on concurrently-held transactions per dataset.
- **Multi-instance deployments are explicitly out of scope for v1.** A held transaction is in-process state on one Fuseki instance; it has no meaning across a farm of Fuseki instances behind a load balancer without a shared coordinator. This should be documented as a hard limitation, not silently broken — an operator running Fuseki behind a non-sticky LB needs to know this feature won't work as expected there.
- Suggest packaging this as an optional `FusekiModule` (`jena-fuseki-main`'s pluggable module mechanism) rather than baking it into `fuseki-core`/`fuseki-main` unconditionally, so operators who don't want the operational risk of server-held locks (timeouts, cap tuning, the multi-instance caveat above) can simply not load it, and the core query/update path is untouched either way.

## Non-goals

- Distributed/XA transactions spanning multiple datasets or multiple Fuseki instances.
- Any change to the semantics or wire format of a plain, non-transactional `/query` or `/update` request.
- TDB1 or non-TDB2 backend support (v1 rejects cleanly; could be revisited later if there's demand).

## Open questions for maintainers

1. Endpoint/header naming above is a starting sketch, not a bikeshed I'm attached to — `SPARQL-Transaction` vs. a query parameter (both were discussed in w3c/sparql-dev#83) is worth a decision before implementation starts.
2. Does packaging as a `FusekiModule` (opt-in) vs. a built-in fuseki-core feature match how you'd want this maintained?
3. Is TDB2-only for v1 acceptable, or is TDB1 parity a hard requirement before this would be considered?

Would the maintainers be open to a PR along these lines? Happy to adjust the design based on feedback before investing in an implementation — in particular I'd rather settle the API shape and TDB1-scope questions above first.

- [x] Are you interested in contributing a solution yourself? Yes, pending agreement on the approach above.

### Are you interested in contributing a solution yourself?

Yes

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.