pl8-base 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pl8_base/__init__.py +1 -0
- pl8_base/const.py +25 -0
- pl8_base/errors.py +86 -0
- pl8_base/manager.py +413 -0
- pl8_base/mixins/__init__.py +9 -0
- pl8_base/mixins/issue.py +805 -0
- pl8_base/mixins/space.py +184 -0
- pl8_base/py.typed +0 -0
- pl8_base/types/__init__.py +43 -0
- pl8_base/types/base.py +125 -0
- pl8_base/types/enums.py +25 -0
- pl8_base/types/events.py +103 -0
- pl8_base/types/issue.py +91 -0
- pl8_base/types/space.py +50 -0
- pl8_base/util.py +338 -0
- pl8_base-0.0.1.dist-info/METADATA +96 -0
- pl8_base-0.0.1.dist-info/RECORD +19 -0
- pl8_base-0.0.1.dist-info/WHEEL +4 -0
- pl8_base-0.0.1.dist-info/licenses/LICENSE +21 -0
pl8_base/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
pl8_base/const.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
MAX_ISSUE_ID_LEN = 16
|
|
4
|
+
MIN_ISSUE_ID_LEN = 3
|
|
5
|
+
RETRY_ISSUE_ID_COLLISIONS = 5
|
|
6
|
+
|
|
7
|
+
# Bounded because space_id is caller-supplied and composes both SPACE#{space_id}
|
|
8
|
+
# and ISSUE#{space_id}#{issue_id}. 64 leaves the composite key far inside
|
|
9
|
+
# DynamoDB's 2048 byte limit.
|
|
10
|
+
MAX_SPACE_ID_LEN = 64
|
|
11
|
+
|
|
12
|
+
# Full-jitter exponential backoff for TransactionConflict retries
|
|
13
|
+
TRANSACT_RETRY_ATTEMPTS = 5
|
|
14
|
+
TRANSACT_RETRY_BASE_DELAY = 0.05
|
|
15
|
+
TRANSACT_RETRY_MAX_DELAY = 1.0
|
|
16
|
+
|
|
17
|
+
# CancellationReasons code marking a transient transaction conflict
|
|
18
|
+
TRANSACT_CONFLICT_REASON = "TransactionConflict"
|
|
19
|
+
# CancellationReasons code marking a failed ConditionExpression
|
|
20
|
+
CONDITION_FAILED_REASON = "ConditionalCheckFailed"
|
|
21
|
+
# Error code a failed ConditionExpression raises outside a transaction
|
|
22
|
+
CONDITION_FAILED_CODE = "ConditionalCheckFailedException"
|
|
23
|
+
|
|
24
|
+
# Name of the single GSI on the base table
|
|
25
|
+
GSI1_INDEX_NAME = "GSI1"
|
pl8_base/errors.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
class DDBError(Exception):
|
|
4
|
+
"""Base class for DDB Issues"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DDBInternalError(DDBError):
|
|
8
|
+
"""Raised for general service errors"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DDBCorruptedError(DDBInternalError):
|
|
12
|
+
"""Raised if data read from database is corrupted"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DDBMissingError(DDBError):
|
|
16
|
+
"""Raised when a requested item does not exist"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DDBExistsError(DDBError):
|
|
20
|
+
"""Raised when an item already exists"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DDBArgsError(DDBError):
|
|
24
|
+
"""Raised on invalid arguments passed in"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DDBIdCollisionError(DDBError):
|
|
28
|
+
"""Raised on ID collision after too many retries"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DDBTransactionConflictError(DDBError):
|
|
32
|
+
"""Raised on transaction conflict.
|
|
33
|
+
|
|
34
|
+
Transient contention. The same request may be retried unchanged; see
|
|
35
|
+
util.retry_on_transaction_conflict.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DDBVersionConflictError(DDBError):
|
|
40
|
+
"""Raised when a write's version condition fails.
|
|
41
|
+
|
|
42
|
+
The caller's view of the item is stale, so retrying the same request will
|
|
43
|
+
fail again. The caller must re-read and reapply its change.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DDBTerminalStatusError(DDBError):
|
|
48
|
+
"""Raised when an operation would move an Issue out of DONE, or would
|
|
49
|
+
mutate an Issue whose DONE status forbids the change.
|
|
50
|
+
|
|
51
|
+
DONE is terminal; see types.enums.IssueStatus for why that rule is
|
|
52
|
+
load-bearing beyond the product requirement.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DDBStillBlockedError(DDBError):
|
|
57
|
+
"""Raised when an Issue would be transitioned out of BLOCKED while it
|
|
58
|
+
still has active IssueBlockers.
|
|
59
|
+
|
|
60
|
+
The caller must delete the remaining IssueBlockers first, or wait for the
|
|
61
|
+
blocking Issues to reach DONE.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DDBBlockingIssueDoneError(DDBError):
|
|
66
|
+
"""Raised when an IssueBlocker would name a DONE Issue as the blocker.
|
|
67
|
+
|
|
68
|
+
A DONE Issue blocks nothing; the relationship would be created already
|
|
69
|
+
satisfied.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EventError(Exception):
|
|
74
|
+
"""Base class for event issues"""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EventSendError(EventError):
|
|
78
|
+
"""Raised when EventBridge rejects or fails to accept an event.
|
|
79
|
+
|
|
80
|
+
Covers both a failed put_events call and a per-entry failure reported
|
|
81
|
+
back with FailedEntryCount > 0.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EventCorruptedError(EventError):
|
|
86
|
+
"""Raised when an event's Detail is missing/unknown type, or malformed"""
|
pl8_base/manager.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import msgspec
|
|
4
|
+
from boto3.dynamodb.types import TypeDeserializer, TypeSerializer
|
|
5
|
+
from botocore.exceptions import ClientError
|
|
6
|
+
|
|
7
|
+
from .const import (
|
|
8
|
+
CONDITION_FAILED_CODE,
|
|
9
|
+
CONDITION_FAILED_REASON,
|
|
10
|
+
TRANSACT_CONFLICT_REASON,
|
|
11
|
+
)
|
|
12
|
+
from .errors import (
|
|
13
|
+
DDBCorruptedError,
|
|
14
|
+
DDBInternalError,
|
|
15
|
+
DDBMissingError,
|
|
16
|
+
DDBTransactionConflictError,
|
|
17
|
+
DDBVersionConflictError,
|
|
18
|
+
)
|
|
19
|
+
from .mixins import IssueMixin, SpaceMixin
|
|
20
|
+
from .types import CLASS_MAP
|
|
21
|
+
from .util import (
|
|
22
|
+
decode_pagination_cursor,
|
|
23
|
+
encode_pagination_cursor,
|
|
24
|
+
isotime,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BasePL8(IssueMixin, SpaceMixin):
|
|
29
|
+
def __init__(self, *, dynamodb_client, table_name, logger):
|
|
30
|
+
"""Init manager.
|
|
31
|
+
Args:
|
|
32
|
+
dynamodb_client (boto3.dynamodb): DynamoDB client
|
|
33
|
+
table_name (str): DynamoDB table name to use
|
|
34
|
+
logger (aws_lambda_powertools.Logger): injected structured
|
|
35
|
+
logger. Extra keyword args are merged into the emitted
|
|
36
|
+
JSON log record.
|
|
37
|
+
"""
|
|
38
|
+
self.dynamodb_client = dynamodb_client
|
|
39
|
+
self.table_name = table_name
|
|
40
|
+
self.logger = logger
|
|
41
|
+
self.ts = TypeSerializer()
|
|
42
|
+
self.td = TypeDeserializer()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def log_client_error(self, exc):
|
|
46
|
+
"""Util method for structured AWS error logging
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
exc (ClientError): exception
|
|
50
|
+
"""
|
|
51
|
+
error_code = exc.response["Error"]["Code"]
|
|
52
|
+
self.logger.exception(f"ClientError (code: {error_code})",
|
|
53
|
+
response=exc.response)
|
|
54
|
+
|
|
55
|
+
def parse_item(self, item):
|
|
56
|
+
"""Util method for parsing a raw dynamodb item into a dataclass.
|
|
57
|
+
|
|
58
|
+
Logs error details if unable to parse.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
item (dict or None): raw dynamodb item
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
BaseObject or None: parsed object, or None if unable to parse
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
DDBCorruptedError: if missing or unknown type, or malformed item
|
|
68
|
+
"""
|
|
69
|
+
if item is None:
|
|
70
|
+
return
|
|
71
|
+
item_type = item.get("type")
|
|
72
|
+
if item_type is None:
|
|
73
|
+
self.logger.error("Malformed item without type", item=item)
|
|
74
|
+
raise DDBCorruptedError("Malformed item without type")
|
|
75
|
+
|
|
76
|
+
item_type = self.td.deserialize(item_type)
|
|
77
|
+
type_class = CLASS_MAP.get(item_type)
|
|
78
|
+
|
|
79
|
+
if type_class is None:
|
|
80
|
+
self.logger.error("Retrieved item with unknown type",
|
|
81
|
+
item_type=item_type, item=item)
|
|
82
|
+
raise DDBCorruptedError(f"Item with unknown type: {item_type}")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
parsed = type_class.from_item(item, td=self.td)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
self.logger.exception("Malformed item", item_type=item_type,
|
|
88
|
+
item=item)
|
|
89
|
+
raise DDBCorruptedError(f"Malformed item: {exc!s}")
|
|
90
|
+
|
|
91
|
+
return parsed
|
|
92
|
+
|
|
93
|
+
def serialize_value(self, value):
|
|
94
|
+
"""Serialize one Python value to a DynamoDB AttributeValue.
|
|
95
|
+
|
|
96
|
+
Goes through msgspec first so enums arrive as their bare values; a
|
|
97
|
+
StrEnum would otherwise be serialized as itself and leak the member
|
|
98
|
+
repr into anything that does not compare it as a string.
|
|
99
|
+
|
|
100
|
+
Bytes skip that step. msgspec encodes them as a base64 *string*, which
|
|
101
|
+
would quietly store a compressed attr as S instead of B and break the
|
|
102
|
+
round-trip back through BaseObject.from_item.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
value: any msgspec-encodable value
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
dict: DynamoDB AttributeValue
|
|
109
|
+
"""
|
|
110
|
+
if isinstance(value, (bytes, bytearray)):
|
|
111
|
+
return self.ts.serialize(value)
|
|
112
|
+
|
|
113
|
+
return self.ts.serialize(msgspec.to_builtins(value))
|
|
114
|
+
|
|
115
|
+
def old_item_from_exc(self, exc):
|
|
116
|
+
"""Parse the pre-write item a failed condition returned, if any.
|
|
117
|
+
|
|
118
|
+
Requires the write to have set ReturnValuesOnConditionCheckFailure to
|
|
119
|
+
ALL_OLD. A missing item means the row did not exist, which is how a
|
|
120
|
+
DDBMissingError is told apart from a condition that genuinely failed.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
exc (ClientError): a ConditionalCheckFailedException
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
BaseObject or None: the item as it was, or None if absent
|
|
127
|
+
"""
|
|
128
|
+
return self.parse_item(exc.response.get("Item"))
|
|
129
|
+
|
|
130
|
+
def cancellation_reasons(self, exc):
|
|
131
|
+
"""Per-item cancellation reasons from a cancelled transaction.
|
|
132
|
+
|
|
133
|
+
Reasons come back positionally, aligned with TransactItems, so callers
|
|
134
|
+
must keep their item ordering stable to read these.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
exc (ClientError): a TransactionCanceledException
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
list[dict]: one reason per transaction item
|
|
141
|
+
"""
|
|
142
|
+
return exc.response.get("CancellationReasons") or []
|
|
143
|
+
|
|
144
|
+
def raise_for_transaction_conflict(self, exc):
|
|
145
|
+
"""Re-raise a cancelled transaction as DDBTransactionConflictError.
|
|
146
|
+
|
|
147
|
+
Contention is transient and the same request may be retried unchanged;
|
|
148
|
+
see util.retry_on_transaction_conflict. Returns without raising if the
|
|
149
|
+
cancellation was for some other reason.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
exc (ClientError): a TransactionCanceledException
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
DDBTransactionConflictError: if any item reports a conflict
|
|
156
|
+
"""
|
|
157
|
+
for reason in self.cancellation_reasons(exc):
|
|
158
|
+
if reason.get("Code") == TRANSACT_CONFLICT_REASON:
|
|
159
|
+
raise DDBTransactionConflictError(
|
|
160
|
+
"Transaction conflict") from exc
|
|
161
|
+
|
|
162
|
+
def get_primary_item(self, *, PK, SK):
|
|
163
|
+
"""Load one item by its primary key.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
PK (str): partition key
|
|
167
|
+
SK (str): sort key
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
BaseObject: the parsed item
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
DDBMissingError: if the item does not exist
|
|
174
|
+
DDBCorruptedError: if the item cannot be parsed
|
|
175
|
+
DDBInternalError: internal database error
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
resp = self.dynamodb_client.get_item(
|
|
179
|
+
TableName=self.table_name,
|
|
180
|
+
Key={
|
|
181
|
+
"PK": self.ts.serialize(PK),
|
|
182
|
+
"SK": self.ts.serialize(SK),
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
except ClientError as exc:
|
|
186
|
+
self.log_client_error(exc)
|
|
187
|
+
raise DDBInternalError(f"Error loading item: {exc!s}") from exc
|
|
188
|
+
|
|
189
|
+
item = resp.get("Item")
|
|
190
|
+
if item is None:
|
|
191
|
+
raise DDBMissingError(f"Item not found: PK={PK} SK={SK}")
|
|
192
|
+
|
|
193
|
+
return self.parse_item(item)
|
|
194
|
+
|
|
195
|
+
def _build_update(self, *, PK, SK, version=None, expected_vals=None,
|
|
196
|
+
excluded_vals=None, increments=None, **attrs):
|
|
197
|
+
"""Build an update dict for update_item or transact_write_items.
|
|
198
|
+
|
|
199
|
+
Every write bumps version and sets updated_at. The version *condition*
|
|
200
|
+
is opt-in, applied only when the caller passes version=.
|
|
201
|
+
|
|
202
|
+
Consumer-facing operations pass version= to fence a caller whose view of
|
|
203
|
+
the item is stale, and get DDBVersionConflictError back if it moved.
|
|
204
|
+
Nearly every background write to an Issue is semantically meaningful to a
|
|
205
|
+
consumer (num_active_blockers, or the status flip that follows it), so
|
|
206
|
+
these are not spurious conflicts.
|
|
207
|
+
|
|
208
|
+
The handle_* event consumers MUST NOT pass version=. They hold no
|
|
209
|
+
consumer's read, and their correctness comes from domain conditions
|
|
210
|
+
instead (is_blocking_issue_done=False, status=BLOCKED,
|
|
211
|
+
num_active_blockers=0), which are also what makes them idempotent under
|
|
212
|
+
at-least-once, unordered delivery. Fencing them on version would make
|
|
213
|
+
event replays fail spuriously.
|
|
214
|
+
|
|
215
|
+
Conditions are only built here; nothing is raised. The conditions fail
|
|
216
|
+
at write time as a single ConditionalCheckFailedException, which the
|
|
217
|
+
caller issuing the write is responsible for mapping onto
|
|
218
|
+
DDBMissingError, DDBVersionConflictError or a domain error.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
PK (str): partition key of the item to update
|
|
222
|
+
SK (str): sort key of the item to update
|
|
223
|
+
version (int or None): if set, condition the write on this version
|
|
224
|
+
expected_vals (dict or None): attr values that must match
|
|
225
|
+
excluded_vals (dict or None): attr values that must NOT match.
|
|
226
|
+
Needed for the DONE checks, since "is terminal" is a rule about
|
|
227
|
+
what an attr must not be and expected_vals only compares equal.
|
|
228
|
+
increments (dict or None): attrs to add to, rather than overwrite.
|
|
229
|
+
Applied by DynamoDB, so a counter stays correct under
|
|
230
|
+
concurrent writers where a read-modify-write would not.
|
|
231
|
+
**attrs: attrs to set
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
dict: update dict for update_item, or to wrap in {"Update": <dict>}
|
|
235
|
+
for transact_write_items
|
|
236
|
+
"""
|
|
237
|
+
# Every attr goes through ExpressionAttributeNames: "status" and
|
|
238
|
+
# "version" are both DynamoDB reserved words, and the rest would be a
|
|
239
|
+
# trap waiting for the next field added.
|
|
240
|
+
expr_attr_names = {"#PK": "PK", "#version": "version"}
|
|
241
|
+
expr_attr_vals = {":version_incr": self.ts.serialize(1)}
|
|
242
|
+
set_clauses = ["#version = #version + :version_incr"]
|
|
243
|
+
|
|
244
|
+
for attr, value in {"updated_at": isotime(), **attrs}.items():
|
|
245
|
+
expr_attr_names[f"#{attr}"] = attr
|
|
246
|
+
expr_attr_vals[f":set_{attr}"] = self.serialize_value(value)
|
|
247
|
+
set_clauses.append(f"#{attr} = :set_{attr}")
|
|
248
|
+
|
|
249
|
+
for attr, delta in (increments or {}).items():
|
|
250
|
+
expr_attr_names[f"#{attr}"] = attr
|
|
251
|
+
expr_attr_vals[f":incr_{attr}"] = self.serialize_value(delta)
|
|
252
|
+
set_clauses.append(f"#{attr} = #{attr} + :incr_{attr}")
|
|
253
|
+
|
|
254
|
+
condition_clauses = ["attribute_exists(#PK)"]
|
|
255
|
+
|
|
256
|
+
if version is not None:
|
|
257
|
+
expr_attr_vals[":expected_version"] = self.serialize_value(version)
|
|
258
|
+
condition_clauses.append("#version = :expected_version")
|
|
259
|
+
|
|
260
|
+
for attr, value in (expected_vals or {}).items():
|
|
261
|
+
expr_attr_names[f"#{attr}"] = attr
|
|
262
|
+
expr_attr_vals[f":expected_{attr}"] = self.serialize_value(value)
|
|
263
|
+
condition_clauses.append(f"#{attr} = :expected_{attr}")
|
|
264
|
+
|
|
265
|
+
for attr, value in (excluded_vals or {}).items():
|
|
266
|
+
expr_attr_names[f"#{attr}"] = attr
|
|
267
|
+
expr_attr_vals[f":excluded_{attr}"] = self.serialize_value(value)
|
|
268
|
+
condition_clauses.append(f"#{attr} <> :excluded_{attr}")
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
"TableName": self.table_name,
|
|
272
|
+
"Key": {
|
|
273
|
+
"PK": self.ts.serialize(PK),
|
|
274
|
+
"SK": self.ts.serialize(SK),
|
|
275
|
+
},
|
|
276
|
+
"UpdateExpression": "SET " + ", ".join(set_clauses),
|
|
277
|
+
"ConditionExpression": " AND ".join(condition_clauses),
|
|
278
|
+
"ExpressionAttributeNames": expr_attr_names,
|
|
279
|
+
"ExpressionAttributeValues": expr_attr_vals,
|
|
280
|
+
# Missing, stale version and an expected_vals mismatch all surface
|
|
281
|
+
# as one ConditionalCheckFailedException; the old item is the only
|
|
282
|
+
# way to tell them apart. In a transaction the per-item reasons come
|
|
283
|
+
# back positionally in CancellationReasons, so item ordering must
|
|
284
|
+
# stay stable.
|
|
285
|
+
"ReturnValuesOnConditionCheckFailure": "ALL_OLD",
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
def is_condition_failure(self, exc):
|
|
289
|
+
"""Whether a ClientError is a failed ConditionExpression.
|
|
290
|
+
|
|
291
|
+
The non-transactional counterpart to raise_for_transaction_conflict:
|
|
292
|
+
outside a transaction a failed condition arrives as its own error code
|
|
293
|
+
rather than in CancellationReasons.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
exc (ClientError): exception
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
bool: True if the write failed its condition
|
|
300
|
+
"""
|
|
301
|
+
return exc.response["Error"]["Code"] == CONDITION_FAILED_CODE
|
|
302
|
+
|
|
303
|
+
def failed_reason_item(self, exc, index):
|
|
304
|
+
"""The old item for one cancelled transaction entry, if it failed.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
exc (ClientError): a TransactionCanceledException
|
|
308
|
+
index (int): position of the entry in TransactItems
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
tuple: (failed, item) where failed is whether that entry's
|
|
312
|
+
condition failed and item is the parsed pre-write item, which
|
|
313
|
+
is None when the row did not exist
|
|
314
|
+
"""
|
|
315
|
+
reasons = self.cancellation_reasons(exc)
|
|
316
|
+
reason = reasons[index] if index < len(reasons) else {}
|
|
317
|
+
|
|
318
|
+
if reason.get("Code") != CONDITION_FAILED_REASON:
|
|
319
|
+
return False, None
|
|
320
|
+
|
|
321
|
+
return True, self.parse_item(reason.get("Item"))
|
|
322
|
+
|
|
323
|
+
def paginate(self, query, **kwargs):
|
|
324
|
+
"""Yield every item from a cursor-based query on this manager."""
|
|
325
|
+
cursor = None
|
|
326
|
+
while True:
|
|
327
|
+
page, cursor = query(cursor=cursor, **kwargs)
|
|
328
|
+
yield from page
|
|
329
|
+
if cursor is None:
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
def run_query(self, params, cursor=None, limit=None):
|
|
333
|
+
"""Run a query and return one page plus a continuation cursor.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
tuple: (list[BaseObject], str or None)
|
|
337
|
+
"""
|
|
338
|
+
params = dict(params, TableName=self.table_name)
|
|
339
|
+
if limit is not None:
|
|
340
|
+
params["Limit"] = limit
|
|
341
|
+
if cursor is not None:
|
|
342
|
+
params["ExclusiveStartKey"] = decode_pagination_cursor(cursor)
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
resp = self.dynamodb_client.query(**params)
|
|
346
|
+
except ClientError as exc:
|
|
347
|
+
self.log_client_error(exc)
|
|
348
|
+
raise DDBInternalError(f"Error running query: {exc!s}") from exc
|
|
349
|
+
|
|
350
|
+
items = [self.parse_item(item) for item in resp.get("Items", [])]
|
|
351
|
+
last_evaluated_key = resp.get("LastEvaluatedKey")
|
|
352
|
+
next_cursor = (encode_pagination_cursor(last_evaluated_key)
|
|
353
|
+
if last_evaluated_key else None)
|
|
354
|
+
|
|
355
|
+
return items, next_cursor
|
|
356
|
+
|
|
357
|
+
def apply_update(self, update, *, entity, ref, version=None, classify=None,
|
|
358
|
+
log_context=None):
|
|
359
|
+
"""Apply a built update and return the item as written.
|
|
360
|
+
|
|
361
|
+
Resolves the two failures every conditional write shares, the row being
|
|
362
|
+
gone and the caller's version being stale, and leaves whatever domain
|
|
363
|
+
condition the caller added to its own classify hook. Entity-agnostic so
|
|
364
|
+
each entity shares those semantics by construction rather than by copy.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
update (dict): an update dict from _build_update
|
|
368
|
+
entity (str): entity name for messages, e.g. "Issue"
|
|
369
|
+
ref (str): identifies the item in error messages
|
|
370
|
+
version (int or None): version the write was conditioned on
|
|
371
|
+
classify (callable or None): called with the pre-write item when a
|
|
372
|
+
domain condition failed, to raise the matching error
|
|
373
|
+
log_context (dict or None): structured fields to log when a
|
|
374
|
+
condition failure cannot be classified
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
BaseObject: the item after the write
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
DDBMissingError: if the item does not exist
|
|
381
|
+
DDBVersionConflictError: if version is set and did not match
|
|
382
|
+
DDBInternalError: internal database error
|
|
383
|
+
"""
|
|
384
|
+
noun = entity.lower()
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
resp = self.dynamodb_client.update_item(**update,
|
|
388
|
+
ReturnValues="ALL_NEW")
|
|
389
|
+
except ClientError as exc:
|
|
390
|
+
if not self.is_condition_failure(exc):
|
|
391
|
+
self.log_client_error(exc)
|
|
392
|
+
raise DDBInternalError(
|
|
393
|
+
f"Error updating {noun}: {exc!s}") from exc
|
|
394
|
+
|
|
395
|
+
old = self.old_item_from_exc(exc)
|
|
396
|
+
|
|
397
|
+
if old is None:
|
|
398
|
+
raise DDBMissingError(f"{entity} not found: {ref}") from exc
|
|
399
|
+
|
|
400
|
+
if version is not None and old.version != version:
|
|
401
|
+
raise DDBVersionConflictError(
|
|
402
|
+
f"{entity} {ref} changed since it was read") from exc
|
|
403
|
+
|
|
404
|
+
if classify is not None:
|
|
405
|
+
classify(old)
|
|
406
|
+
|
|
407
|
+
self.logger.error(
|
|
408
|
+
f"Unclassified condition failure updating {noun}",
|
|
409
|
+
**(log_context or {}))
|
|
410
|
+
raise DDBInternalError(
|
|
411
|
+
f"Error updating {noun}: {ref}") from exc
|
|
412
|
+
|
|
413
|
+
return self.parse_item(resp["Attributes"])
|