Skip to content

Commit 9698e3c

Browse files
authored
Merge pull request #33 from marklogic/feature/with-transaction
DEVEXP-561 Now supporting REST API transactions
2 parents 216ea24 + e4df27d commit 9698e3c

File tree

6 files changed

+315
-5
lines changed

6 files changed

+315
-5
lines changed

docs/Gemfile.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ GEM
251251

252252
PLATFORMS
253253
arm64-darwin-21
254+
arm64-darwin-23
254255

255256
DEPENDENCIES
256257
github-pages (~> 228)

docs/transactions.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
---
2+
layout: default
3+
title: Managing transactions
4+
nav_order: 4
5+
---
6+
7+
The [/v1/transactions endpoint](https://docs.marklogic.com/REST/client/transaction-management)
8+
in the MarkLogic REST API supports managing a transaction that can be referenced in
9+
multiple separate calls to other REST API endpoints, with all calls being committed or
10+
rolled back together. The MarkLogic Python client simplifies usage of these endpoints
11+
via a `Transaction` class that is also a
12+
[Python context manager](https://docs.python.org/3/reference/datamodel.html#context-managers),
13+
thereby allowing it to handle committing or rolling back the transaction without any user
14+
involvement.
15+
16+
The following example demonstrates writing documents via multiple calls to MarkLogic,
17+
all within the same REST API transaction; the example depends on first following the
18+
instructions in the [setup guide](example-setup.md):
19+
20+
```
21+
from marklogic import Client
22+
from marklogic.documents import Document
23+
client = Client('http://localhost:8000', digest=('python-user', 'pyth0n'))
24+
25+
default_perms = {"rest-reader": ["read", "update"]}
26+
doc1 = Document("/tx/doc1.json", {"doc": 1}, permissions=default_perms)
27+
doc2 = Document("/tx/doc2.json", {"doc": 2}, permissions=default_perms)
28+
29+
with client.transactions.create() as tx:
30+
client.documents.write(doc1, tx=tx).raise_for_status()
31+
client.documents.write(doc2, tx=tx).raise_for_status()
32+
```
33+
34+
The `client.transactions.create()` function returns a `Transaction` instance that acts
35+
as the context manager. When the `with` block completes, the `Transaction` instance
36+
calls the REST API to commit the transaction.
37+
38+
As of 1.1.0, each of the functions in the `client.documents` object can include a
39+
reference to the transaction to ensure that the `read` or `write` or `search` operation
40+
occurs within the REST API transaction.
41+
42+
## Ensuring a transaction is rolled back
43+
44+
The `requests` function [`raise_for_status()`](https://requests.readthedocs.io/en/latest/user/quickstart/#errors-and-exceptions)
45+
is used in the example above to ensure that if a request fails, an error is thrown,
46+
causing the transaction to be rolled back. The following example demonstrates a rolled
47+
back transaction due to an invalid JSON object that causes a `write` operation to fail:
48+
49+
```
50+
doc1 = Document("/tx/doc1.json", {"doc": 1}, permissions=default_perms)
51+
doc2 = Document("/tx/doc2.json", "invalid json", permissions=default_perms)
52+
53+
with client.transactions.create() as tx:
54+
client.documents.write(doc1, tx=tx).raise_for_status()
55+
client.documents.write(doc2, tx=tx).raise_for_status()
56+
```
57+
58+
The above will cause a `requests` `HTTPError` instance to be thrown, and the first
59+
document will not be written due to the transaction being rolled back.
60+
61+
You are free to check the status code of the response object returned
62+
by each call as well; `raise_for_status()` is simply a commonly used convenience in the
63+
`requests` library.
64+
65+
## Using the transaction request parameter
66+
67+
You can reference the transaction when calling any REST API endpoint that supports the
68+
optional `txid` request parameter. The following example demonstrates this, reusing the
69+
same `client` instance from the first example:
70+
71+
```
72+
with client.transactions.create() as tx:
73+
client.post("/v1/resources/my-resource", params={"txid": tx.id})
74+
client.delete("/v1/resources/other-resource", params={"txid": tx.id})
75+
```
76+
77+
## Getting transaction status
78+
79+
You can get the status of the transaction via the `get_status()` function:
80+
81+
```
82+
with client.transactions.create() as tx:
83+
print(f"Transaction status: {tx.get_status()}")
84+
```

marklogic/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from marklogic.cloud_auth import MarkLogicCloudAuth
33
from marklogic.documents import DocumentManager
44
from marklogic.rows import RowManager
5+
from marklogic.transactions import TransactionManager
56
from requests.auth import HTTPDigestAuth
67
from urllib.parse import urljoin
78

@@ -77,3 +78,9 @@ def rows(self):
7778
if not hasattr(self, "_rows"):
7879
self._rows = RowManager(self)
7980
return self._rows
81+
82+
@property
83+
def transactions(self):
84+
if not hasattr(self, "_transactions"):
85+
self._transactions = TransactionManager(self)
86+
return self._transactions

marklogic/documents.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
from collections import OrderedDict
33
from typing import Union
44

5+
from marklogic.transactions import Transaction
56
from requests import Response, Session
67
from requests_toolbelt.multipart.decoder import MultipartDecoder
78
from urllib3.fields import RequestField
89
from urllib3.filepost import encode_multipart_formdata
910

1011
"""
1112
Defines classes to simplify usage of the documents REST endpoint defined at
12-
https://docs.marklogic.com/REST/client/management.
13+
https://docs.marklogic.com/REST/client/management.
1314
"""
1415

1516

@@ -147,7 +148,7 @@ def __init__(
147148
@property
148149
def metadata(self):
149150
"""
150-
Returns a dict containing the 5 attributes that comprise the metadata of a
151+
Returns a dict containing the 5 attributes that comprise the metadata of a
151152
document in MarkLogic.
152153
"""
153154
return {
@@ -344,7 +345,10 @@ def __init__(self, session: Session):
344345
self._session = session
345346

346347
def write(
347-
self, parts: Union[Document, list[Union[DefaultMetadata, Document]]], **kwargs
348+
self,
349+
parts: Union[Document, list[Union[DefaultMetadata, Document]]],
350+
tx: Transaction = None,
351+
**kwargs,
348352
) -> Response:
349353
"""
350354
Write one or many documents at a time via a POST to the endpoint defined at
@@ -355,6 +359,7 @@ def write(
355359
after it that does not define its own metadata. See
356360
https://docs.marklogic.com/guide/rest-dev/bulk#id_16015 for more information on
357361
how the REST endpoint uses metadata.
362+
:param tx: if set, the request will be associated with the given transaction.
358363
"""
359364
fields = []
360365

@@ -374,17 +379,27 @@ def write(
374379

375380
data, content_type = encode_multipart_formdata(fields)
376381

382+
params = kwargs.pop("params", {})
383+
if tx:
384+
params["txid"] = tx.id
385+
377386
headers = kwargs.pop("headers", {})
378387
headers["Content-Type"] = "".join(
379388
("multipart/mixed",) + content_type.partition(";")[1:]
380389
)
381390
if not headers.get("Accept"):
382391
headers["Accept"] = "application/json"
383392

384-
return self._session.post("/v1/documents", data=data, headers=headers, **kwargs)
393+
return self._session.post(
394+
"/v1/documents", data=data, headers=headers, params=params, **kwargs
395+
)
385396

386397
def read(
387-
self, uris: Union[str, list[str]], categories: list[str] = None, **kwargs
398+
self,
399+
uris: Union[str, list[str]],
400+
categories: list[str] = None,
401+
tx: Transaction = None,
402+
**kwargs,
388403
) -> Union[list[Document], Response]:
389404
"""
390405
Read one or many documents via a GET to the endpoint defined at
@@ -395,12 +410,15 @@ def read(
395410
:param categories: optional list of the categories of data to return for each
396411
URI. By default, only content will be returned for each URI. See the endpoint
397412
documentation for further information.
413+
:param tx: if set, the request will be associated with the given transaction.
398414
"""
399415
params = kwargs.pop("params", {})
400416
params["uri"] = uris if isinstance(uris, list) else [uris]
401417
params["format"] = "json" # This refers to the metadata format.
402418
if categories:
403419
params["category"] = categories
420+
if tx:
421+
params["txid"] = tx.id
404422

405423
headers = kwargs.pop("headers", {})
406424
headers["Accept"] = "multipart/mixed"
@@ -423,6 +441,7 @@ def search(
423441
page_length: int = None,
424442
options: str = None,
425443
collections: list[str] = None,
444+
tx: Transaction = None,
426445
**kwargs,
427446
) -> Union[list[Document], Response]:
428447
"""
@@ -442,6 +461,7 @@ def search(
442461
:param page_length: maximum number of documents to return.
443462
:param options: name of a query options instance to use.
444463
:param collections: restrict results to documents in these collections.
464+
:param tx: if set, the request will be associated with the given transaction.
445465
"""
446466
params = kwargs.pop("params", {})
447467
params["format"] = "json" # This refers to the metadata format.
@@ -457,6 +477,8 @@ def search(
457477
params["pageLength"] = page_length
458478
if options:
459479
params["options"] = options
480+
if tx:
481+
params["txid"] = tx.id
460482

461483
headers = kwargs.pop("headers", {})
462484
headers["Accept"] = "multipart/mixed"

marklogic/transactions.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import logging
2+
from requests import Response, Session
3+
4+
logger = logging.getLogger(__name__)
5+
6+
"""
7+
Defines classes to simplify usage of the REST endpoints defined at
8+
https://docs.marklogic.com/REST/client/transaction-management for managing transactions.
9+
"""
10+
11+
12+
class Transaction:
13+
"""
14+
Represents a transaction created via
15+
https://docs.marklogic.com/REST/POST/v1/transactions .
16+
17+
An instance of this class can act as a Python context manager and can thus be used
18+
with the Python "with" keyword. This is the intended use case, allowing a user to
19+
perform one to many calls to MarkLogic within the "with" block, each referencing the
20+
ID associated with this transaction. When the "with" block concludes, the
21+
transaction will be automatically committed if no error was thrown, and rolled back
22+
otherwise.
23+
24+
:param id: the ID of the new transaction, which is used for all subsequent
25+
operations involving the transaction.
26+
:param session: a requests Session object that is required for either committing or
27+
rolling back the transaction, as well as for obtaining status of the transaction.
28+
"""
29+
30+
def __init__(self, id: str, session: Session):
31+
self.id = id
32+
self._session = session
33+
34+
def __enter__(self):
35+
return self
36+
37+
def get_status(self) -> dict:
38+
"""
39+
Retrieve transaction status via
40+
https://docs.marklogic.com/REST/GET/v1/transactions/[txid].
41+
"""
42+
return self._session.get(
43+
f"/v1/transactions/{self.id}", headers={"Accept": "application/json"}
44+
).json()
45+
46+
def commit(self) -> Response:
47+
"""
48+
Commits the transaction via
49+
https://docs.marklogic.com/REST/POST/v1/transactions/[txid]. This is expected to be
50+
invoked automatically via a Python context manager.
51+
"""
52+
logger.debug(f"Committing transaction with ID: {self.id}")
53+
return self._session.post(
54+
f"/v1/transactions/{self.id}", params={"result": "commit"}
55+
)
56+
57+
def rollback(self) -> Response:
58+
"""
59+
Rolls back the transaction via
60+
https://docs.marklogic.com/REST/POST/v1/transactions/[txid]. This is expected to be
61+
invoked automatically via a Python context manager.
62+
"""
63+
logger.debug(f"Rolling back transaction with ID: {self.id}")
64+
return self._session.post(
65+
f"/v1/transactions/{self.id}", params={"result": "rollback"}
66+
)
67+
68+
def __exit__(self, *args):
69+
response = (
70+
self.rollback()
71+
if len(args) > 1 and isinstance(args[1], Exception)
72+
else self.commit()
73+
)
74+
assert (
75+
204 == response.status_code
76+
), f"Could not end transaction; cause: {response.text}"
77+
78+
79+
class TransactionManager:
80+
def __init__(self, session: Session):
81+
self._session = session
82+
83+
def create(self, name=None, time_limit=None, database=None) -> Transaction:
84+
"""
85+
Creates a new transaction via https://docs.marklogic.com/REST/POST/v1/transactions.
86+
Contrary to the docs, a Location header is not returned, but the transaction data
87+
is. And the Accept header can be used to control the format of the transaction data.
88+
89+
The returned Transaction is a Python context manager and is intended to be used
90+
via the Python "with" keyword.
91+
92+
:param name: optional name for the transaction.
93+
:param time_limit: optional time limit, in seconds, until the server cancels the
94+
transaction.
95+
:param database: optional database to associate with the transaction.
96+
"""
97+
params = {}
98+
if name:
99+
params["name"] = name
100+
if time_limit:
101+
params["timeLimit"] = time_limit
102+
if database:
103+
params["database"] = database
104+
105+
response = self._session.post(
106+
"/v1/transactions", params=params, headers={"Accept": "application/json"}
107+
)
108+
id = response.json()["transaction-status"]["transaction-id"]
109+
return Transaction(id, self._session)

0 commit comments

Comments
 (0)