pushframe 5.0.0__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.
- pushframe/__init__.py +4 -0
- pushframe/api/__init__.py +0 -0
- pushframe/api/accountApi.py +72 -0
- pushframe/api/activityApi.py +87 -0
- pushframe/api/assetApi.py +194 -0
- pushframe/api/baseApi.py +6 -0
- pushframe/api/frameApi.py +271 -0
- pushframe/api/notificationApi.py +15 -0
- pushframe/api/peopleApi.py +25 -0
- pushframe/api/playlistApi.py +9 -0
- pushframe/aura.py +182 -0
- pushframe/aws/__init__.py +0 -0
- pushframe/aws/awsclient.py +23 -0
- pushframe/aws/s3client.py +40 -0
- pushframe/aws/sqsclient.py +33 -0
- pushframe/cache.py +50 -0
- pushframe/cli.py +1134 -0
- pushframe/client.py +267 -0
- pushframe/exif.py +147 -0
- pushframe/export.py +53 -0
- pushframe/google/__init__.py +43 -0
- pushframe/google/bootstrap.py +134 -0
- pushframe/google/cache.py +167 -0
- pushframe/google/client.py +140 -0
- pushframe/google/enumerate.py +270 -0
- pushframe/google/manifest.py +111 -0
- pushframe/google/parsers.py +345 -0
- pushframe/google/redaction.py +33 -0
- pushframe/google/vault.py +126 -0
- pushframe/gsync.py +463 -0
- pushframe/migration.py +86 -0
- pushframe/models/__init__.py +0 -0
- pushframe/models/activity.py +79 -0
- pushframe/models/asset.py +159 -0
- pushframe/models/frame.py +105 -0
- pushframe/models/meta.py +11 -0
- pushframe/models/person.py +24 -0
- pushframe/models/user.py +22 -0
- pushframe/ratelimit.py +222 -0
- pushframe/reconcile.py +384 -0
- pushframe/sync.py +1105 -0
- pushframe/utils/dt.py +15 -0
- pushframe/utils/io.py +23 -0
- pushframe/utils/settings.py +59 -0
- pushframe-5.0.0.dist-info/METADATA +53 -0
- pushframe-5.0.0.dist-info/RECORD +49 -0
- pushframe-5.0.0.dist-info/WHEEL +4 -0
- pushframe-5.0.0.dist-info/entry_points.txt +2 -0
- pushframe-5.0.0.dist-info/licenses/LICENSE +31 -0
pushframe/reconcile.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Data hygiene for stuck placeholder rows on an existing frame -- deliberately
|
|
2
|
+
NOT part of the sync loop (`pushframe/sync.py`).
|
|
3
|
+
|
|
4
|
+
`select_asset` calls whose upload never completed leave rows with no
|
|
5
|
+
`uploaded_at`, no `file_name` and no `md5_hash`. Neither existing removal
|
|
6
|
+
primitive clears them (`AssetApi.delete_asset` returns 200 and removes
|
|
7
|
+
nothing; `FrameApi.remove_asset` 404s). This module accounts for them: it
|
|
8
|
+
reports how many exist (unconditionally, whether or not any removal
|
|
9
|
+
mechanism works) and, only when explicitly asked, attempts a bounded,
|
|
10
|
+
gated removal.
|
|
11
|
+
|
|
12
|
+
Following `pushframe/sync.py`'s own pure-diff/mutating-execute split:
|
|
13
|
+
`find_placeholders` is this module's pure classifying function -- no I/O,
|
|
14
|
+
no network, no mutation of its input. `apply_reconciliation` is this
|
|
15
|
+
module's ONLY mutating function -- the sole place that calls a removal
|
|
16
|
+
primitive against a live frame, reachable only when the caller explicitly
|
|
17
|
+
asks for it.
|
|
18
|
+
|
|
19
|
+
This module imports nothing Google-side, so it is decoupled from wherever
|
|
20
|
+
the Google side of this milestone eventually lands. It DOES reuse
|
|
21
|
+
`pushframe/sync.py`'s existing pacing primitives (`_chunked`,
|
|
22
|
+
`WRITE_THROTTLE_SECONDS`, `WRITE_BATCH_SIZE`) rather than duplicating them --
|
|
23
|
+
that is a one-way dependency only: `pushframe/sync.py` never imports from
|
|
24
|
+
or calls into this module (grep-verified absent, mirroring D-06's
|
|
25
|
+
structural-isolation convention in `sync.py` itself).
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
|
|
32
|
+
from pushframe.client import RateLimitError
|
|
33
|
+
from pushframe.models.asset import AssetPartialId
|
|
34
|
+
from pushframe.sync import WRITE_BATCH_SIZE, WRITE_THROTTLE_SECONDS, _chunked
|
|
35
|
+
from pushframe.utils.dt import get_utc_now, parse_aura_dt
|
|
36
|
+
|
|
37
|
+
# How long a row matching the placeholder predicate (D-14) must sit before it
|
|
38
|
+
# is even a candidate for removal (D-15). A row created seconds ago by a
|
|
39
|
+
# legitimate in-progress upload matches the predicate exactly -- the server
|
|
40
|
+
# processes an upload asynchronously, so a freshly-registered asset is
|
|
41
|
+
# indistinguishable from a genuinely stuck one until enough time has passed
|
|
42
|
+
# for normal processing to have finished (`tests/test_cli_inspect.py::
|
|
43
|
+
# test_inspect_tolerates_unprocessed_placeholder_asset` already covers a
|
|
44
|
+
# mid-processing asset with the exact same null shape). 24 hours is generous
|
|
45
|
+
# relative to how quickly the app's own uploads are observed to process
|
|
46
|
+
# (minutes, not hours) -- the cost of waiting an extra day before a genuinely
|
|
47
|
+
# stuck row is reported as removable is far lower than the cost of proposing
|
|
48
|
+
# to remove a photo that was still mid-upload. Overridable via
|
|
49
|
+
# `find_placeholders`'/`apply_reconciliation`'s `age_threshold_seconds`
|
|
50
|
+
# (the CLI's `--max-age-hours`).
|
|
51
|
+
RECONCILE_AGE_THRESHOLD_SECONDS = 86400
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ReconcileResult:
|
|
56
|
+
"""Classification of a frame's assets against the placeholder predicate
|
|
57
|
+
(D-14) and the age guard (D-15). Mirrors `pushframe/sync.py`'s
|
|
58
|
+
`ExecutionResult`/`SyncPlan` plain-dataclass shape.
|
|
59
|
+
|
|
60
|
+
`stuck`/`recently_created`/`unknown_age` are populated by
|
|
61
|
+
`find_placeholders` (pure); `removed`/`failed` are populated by
|
|
62
|
+
`apply_reconciliation` (mutating) acting on `stuck` only -- a row in
|
|
63
|
+
`recently_created` or `unknown_age` is structurally never passed to a
|
|
64
|
+
removal mechanism (D-15).
|
|
65
|
+
"""
|
|
66
|
+
stuck: list = field(default_factory=list)
|
|
67
|
+
recently_created: list = field(default_factory=list)
|
|
68
|
+
unknown_age: list = field(default_factory=list)
|
|
69
|
+
removed: list = field(default_factory=list)
|
|
70
|
+
failed: list = field(default_factory=list)
|
|
71
|
+
total_scanned: int = 0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def placeholder_count(self) -> int:
|
|
75
|
+
"""The single number both `inspect` and `reconcile` print -- computed
|
|
76
|
+
from one place so the two can never disagree (D-13)."""
|
|
77
|
+
return len(self.stuck) + len(self.recently_created) + len(self.unknown_age)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _creation_instant(asset):
|
|
81
|
+
"""Resolve `asset.created_at` to a parsed datetime, or `None` when the
|
|
82
|
+
value is absent, empty, or unparseable. Never raises -- an asset from an
|
|
83
|
+
undocumented, drifting API must never crash this module's pure
|
|
84
|
+
classifier; an unresolvable creation time is handled by the caller as
|
|
85
|
+
`unknown_age` (D-15's "fails toward not deleting" rule), not as an
|
|
86
|
+
exception.
|
|
87
|
+
"""
|
|
88
|
+
raw = getattr(asset, 'created_at', None)
|
|
89
|
+
if not raw:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
return parse_aura_dt(raw)
|
|
93
|
+
except (ValueError, TypeError):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: Valid values for `find_placeholders`' `unknown_age_policy` keyword-only
|
|
98
|
+
#: argument (Plan 11-06, Task 1). `'unknown_age'` is the default and
|
|
99
|
+
#: reproduces D-15's original behaviour byte-for-byte. `'stuck'` is the
|
|
100
|
+
#: explicit opt-in that supersedes D-15's *unconditional* form -- see the
|
|
101
|
+
#: `unknown_age_policy` docstring section below for why the unconditional
|
|
102
|
+
#: form needed correcting.
|
|
103
|
+
UNKNOWN_AGE_POLICIES = frozenset({'unknown_age', 'stuck'})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def find_placeholders(assets, *, now=None,
|
|
107
|
+
age_threshold_seconds: float = RECONCILE_AGE_THRESHOLD_SECONDS,
|
|
108
|
+
unknown_age_policy: str = 'unknown_age') -> ReconcileResult:
|
|
109
|
+
"""Classify `assets` into stuck / recently-created / unknown-age
|
|
110
|
+
placeholders (D-13/D-14/D-15). Pure -- no I/O, no network, no mutation
|
|
111
|
+
of `assets` or any element of it.
|
|
112
|
+
|
|
113
|
+
D-14: a row is a placeholder candidate only under the STRICT three-way
|
|
114
|
+
conjunction `uploaded_at is None and file_name is None and md5_hash is
|
|
115
|
+
None`. This is deliberately narrower than the diff engine's own
|
|
116
|
+
hashless-asset bucket in `pushframe/sync.py`'s `compute_plan`, which
|
|
117
|
+
counts every hashless asset including every video on the frame -- a
|
|
118
|
+
video is hashless by design but does carry `file_name` and
|
|
119
|
+
`uploaded_at`, so it trips at most one of the three conditions here and
|
|
120
|
+
is excluded. A partially-hydrated asset (e.g. only `md5_hash` null,
|
|
121
|
+
mid-server-side processing) likewise trips at most one condition and is
|
|
122
|
+
excluded. This predicate is UNCHANGED by `unknown_age_policy` below --
|
|
123
|
+
a row that never matched the three-way conjunction in the first place
|
|
124
|
+
is not affected by anything past this point, no matter which policy is
|
|
125
|
+
in effect.
|
|
126
|
+
|
|
127
|
+
D-15, corrected by plan 11-06 (2026-09-03): a matching row's creation
|
|
128
|
+
time decides which bucket it lands in -- but only WHEN a creation time
|
|
129
|
+
is actually resolvable. `_creation_instant` returning `None` used to be
|
|
130
|
+
handled unconditionally: parked in `unknown_age`, exactly like a
|
|
131
|
+
too-young row, no matter what. Plan 11-05 established live, from the
|
|
132
|
+
raw JSON payload rather than the parsed model, that
|
|
133
|
+
`/frames/{id}/assets.json` NEVER sends a `created_at` key at all -- not
|
|
134
|
+
"sometimes unresolvable while processing", but structurally absent on
|
|
135
|
+
every asset, including fully-processed ones. Against that API, the
|
|
136
|
+
unconditional form does not fail *conservatively* -- it fails *inertly*:
|
|
137
|
+
every placeholder row, no matter how old, is permanently unreachable by
|
|
138
|
+
the removal path, and no `age_threshold_seconds` value can ever change
|
|
139
|
+
that. A guard that can never let a genuinely stuck row through is not
|
|
140
|
+
doing D-15's job of telling old rows from young ones; it is silently
|
|
141
|
+
disabling the feature it guards.
|
|
142
|
+
|
|
143
|
+
`unknown_age_policy` corrects this without touching what made D-15
|
|
144
|
+
correct in the first place:
|
|
145
|
+
|
|
146
|
+
- `'unknown_age'` (the default): reproduces the original, unconditional
|
|
147
|
+
behaviour EXACTLY -- an unresolvable creation time lands in
|
|
148
|
+
`unknown_age`, never `stuck`. A call to `find_placeholders` that does
|
|
149
|
+
not pass this argument is byte-for-byte identical to before this
|
|
150
|
+
correction existed. Nothing becomes eligible for removal by accident.
|
|
151
|
+
- `'stuck'`: an explicit, caller-named opt-in. An unresolvable creation
|
|
152
|
+
time is now treated as eligible (`stuck`) rather than parked. This
|
|
153
|
+
argument is keyword-only and takes an explicit string naming what it
|
|
154
|
+
does -- there is no positional slot a stray argument could fall into,
|
|
155
|
+
and no bare boolean whose meaning depends on reading the call site.
|
|
156
|
+
Choosing this policy is a decision the CALLER makes deliberately, not
|
|
157
|
+
a mode `find_placeholders` defaults into.
|
|
158
|
+
|
|
159
|
+
The strict three-way-null predicate above is untouched by either
|
|
160
|
+
policy. `recently_created` semantics are also untouched: a row with a
|
|
161
|
+
RESOLVABLE instant that is simply too young still lands in
|
|
162
|
+
`recently_created` regardless of `unknown_age_policy` -- the policy
|
|
163
|
+
governs only the unresolvable case, never the "young but known" case.
|
|
164
|
+
|
|
165
|
+
:param assets: The frame's assets (e.g. from `Aura.get_all_assets`).
|
|
166
|
+
:param now: The current instant, injected (mirrors `execute_plan`'s
|
|
167
|
+
`clock` seam) so tests can control time deterministically. Defaults
|
|
168
|
+
to `get_utc_now()` when `None`.
|
|
169
|
+
:param age_threshold_seconds: Minimum age (in seconds) for a matching
|
|
170
|
+
row to be reported as `stuck` rather than `recently_created`. See
|
|
171
|
+
`RECONCILE_AGE_THRESHOLD_SECONDS`.
|
|
172
|
+
:param unknown_age_policy: `'unknown_age'` (default) or `'stuck'`. See
|
|
173
|
+
above. Any other value raises `ValueError` -- fails closed on a
|
|
174
|
+
typo rather than silently falling back to a default that changes
|
|
175
|
+
classification.
|
|
176
|
+
:return: A `ReconcileResult` with `stuck`/`recently_created`/
|
|
177
|
+
`unknown_age` populated and `removed`/`failed` left empty (this
|
|
178
|
+
function never mutates anything).
|
|
179
|
+
"""
|
|
180
|
+
if unknown_age_policy not in UNKNOWN_AGE_POLICIES:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"unknown_age_policy={unknown_age_policy!r} is not one of {sorted(UNKNOWN_AGE_POLICIES)!r}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if now is None:
|
|
186
|
+
now = get_utc_now()
|
|
187
|
+
|
|
188
|
+
result = ReconcileResult(total_scanned=len(assets))
|
|
189
|
+
|
|
190
|
+
for asset in assets:
|
|
191
|
+
if not (asset.uploaded_at is None and asset.file_name is None and asset.md5_hash is None):
|
|
192
|
+
# Not a placeholder candidate at all -- a video (hashless but
|
|
193
|
+
# named and uploaded) or a partially-hydrated asset each trip at
|
|
194
|
+
# most one of the three conditions and are skipped entirely.
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
instant = _creation_instant(asset)
|
|
198
|
+
if instant is None:
|
|
199
|
+
if unknown_age_policy == 'stuck':
|
|
200
|
+
result.stuck.append(asset)
|
|
201
|
+
else:
|
|
202
|
+
result.unknown_age.append(asset)
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
age_seconds = (now - instant).total_seconds()
|
|
206
|
+
if age_seconds < age_threshold_seconds:
|
|
207
|
+
result.recently_created.append(asset)
|
|
208
|
+
else:
|
|
209
|
+
result.stuck.append(asset)
|
|
210
|
+
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# D-16, UPDATED by plan 11-06 (2026-09-03): 'remove' (`FrameApi.remove_asset`)
|
|
215
|
+
# is now CONFIRMED to clear these rows -- see 11-LIVE-FINDINGS.md's "Plan
|
|
216
|
+
# 11-06" section for the command, raw HTTP response and follow-up-read
|
|
217
|
+
# verification. Before that plan, no removal mechanism had been confirmed
|
|
218
|
+
# to work (`delete_asset` returns 200 and removes nothing; `remove_asset`
|
|
219
|
+
# had previously 404d -- Phase 10 UAT), because the age guard made
|
|
220
|
+
# `result.stuck` permanently empty against this API (see
|
|
221
|
+
# `find_placeholders`' `unknown_age_policy` docstring). The cap below
|
|
222
|
+
# predates that finding and still applies: a first live attempt is a
|
|
223
|
+
# bounded, time-boxed probe, not a bulk operation, regardless of which
|
|
224
|
+
# mechanism the caller passes -- a cap keeps a mechanism that silently does
|
|
225
|
+
# nothing from burning the whole write budget before the operator notices,
|
|
226
|
+
# and keeps a mechanism that turns out to be destructive from acting on
|
|
227
|
+
# every known row at once. `apply_reconciliation` raises when
|
|
228
|
+
# `len(result.stuck)` exceeds this unless the caller explicitly passes a
|
|
229
|
+
# higher `candidate_limit`.
|
|
230
|
+
RECONCILE_PROBE_CANDIDATE_LIMIT = 25
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _complete_placeholder(aura, frame_id, chunk):
|
|
234
|
+
"""Placeholder for the 'complete' mechanism (D-16): treating a stuck row
|
|
235
|
+
as an incomplete upload to FINISH -- batch_update-ing it with real
|
|
236
|
+
file_name/md5_hash/uploaded_at so it becomes an ordinary asset the
|
|
237
|
+
existing hide/remove paths already handle -- rather than a bad row to
|
|
238
|
+
delete. Not yet implemented, and remains UNTESTED as of plan 11-06
|
|
239
|
+
(2026-09-03) -- not because it failed, but because it was never needed:
|
|
240
|
+
plan 11-06's own live probe used `unknown_age_policy='stuck'` (this
|
|
241
|
+
module's new opt-in) to reach a non-empty `stuck` bucket for the first
|
|
242
|
+
time, tried 'remove' first, and 'remove' (`FrameApi.remove_asset`)
|
|
243
|
+
cleared all 3 targeted rows outright -- confirmed by a follow-up read,
|
|
244
|
+
not just an HTTP 200. With a working, non-destructive, already-built
|
|
245
|
+
mechanism in hand, the probe never proceeded to 'hard-delete' or
|
|
246
|
+
'complete' on those rows (see 11-LIVE-FINDINGS.md, "Plan 11-06"
|
|
247
|
+
section, for the full mechanism table and raw evidence). 'complete'
|
|
248
|
+
stays a reasonable idea for a future need (e.g. if 'remove' ever stops
|
|
249
|
+
working, or a caller wants FINISHED rows rather than absent ones), but
|
|
250
|
+
building and live-testing it is no longer this phase's blocking
|
|
251
|
+
question -- REL-05 already has a confirmed answer without it."""
|
|
252
|
+
raise NotImplementedError(
|
|
253
|
+
"The 'complete' mechanism is not yet implemented. It was not needed by plan 11-06 "
|
|
254
|
+
"(2026-09-03): 'remove' (FrameApi.remove_asset) already cleared the probed rows "
|
|
255
|
+
"outright once the age guard's unknown_age_policy='stuck' opt-in made them eligible "
|
|
256
|
+
"-- see the docstring above and 11-LIVE-FINDINGS.md."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Dispatch table mirroring `pushframe/sync.py`'s `_REMOVAL_PRIMITIVE` shape.
|
|
261
|
+
# UPDATED by plan 11-06 (2026-09-03): 'remove' is CONFIRMED WORKING -- see
|
|
262
|
+
# 11-LIVE-FINDINGS.md's "Plan 11-06" section for the command, raw HTTP
|
|
263
|
+
# response (`{"number_failed":0}`) and the follow-up-read verification that
|
|
264
|
+
# the 3 targeted rows were actually gone, not just acknowledged with a 200.
|
|
265
|
+
# This supersedes the Phase 10 UAT finding that `remove_asset` 404d --
|
|
266
|
+
# that prior probe never had a genuinely `stuck`-classified row to send,
|
|
267
|
+
# because the age guard's pre-11-06 unconditional form made `result.stuck`
|
|
268
|
+
# permanently empty against this API (see `find_placeholders`'
|
|
269
|
+
# `unknown_age_policy` docstring). 'hard-delete' remains unconfirmed --
|
|
270
|
+
# plan 11-06's probe never needed it, since 'remove' cleared every targeted
|
|
271
|
+
# row. 'complete' is still the untried third option (D-16); it raises
|
|
272
|
+
# until a future plan builds it out, though REL-05 no longer depends on it.
|
|
273
|
+
_RECONCILE_PRIMITIVE = {
|
|
274
|
+
'remove': lambda aura, frame_id, chunk: aura.frame_api.remove_asset(
|
|
275
|
+
frame_id, [AssetPartialId(id=asset.id) for asset in chunk]),
|
|
276
|
+
'hard-delete': lambda aura, frame_id, chunk: [
|
|
277
|
+
aura.asset_api.delete_asset(asset) for asset in chunk],
|
|
278
|
+
'complete': _complete_placeholder,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# Requests a removal chunk actually costs, per mechanism -- 'remove' is a
|
|
282
|
+
# batch endpoint (one call regardless of chunk size, mirroring
|
|
283
|
+
# pushframe/sync.py's _REMOVAL_REQUEST_COST['delete']); 'hard-delete' has
|
|
284
|
+
# no batch form and is charged per asset for the same reason
|
|
285
|
+
# pushframe/sync.py charges hard_delete per asset (a chunk-level charge of
|
|
286
|
+
# 1 would let it run the budget dry unnoticed). 'complete' is provisionally
|
|
287
|
+
# charged like 'hard-delete' (a per-asset batch_update call) pending 11-05.
|
|
288
|
+
_RECONCILE_REQUEST_COST = {
|
|
289
|
+
'remove': lambda chunk: 1,
|
|
290
|
+
'hard-delete': lambda chunk: len(chunk),
|
|
291
|
+
'complete': lambda chunk: len(chunk),
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def apply_reconciliation(result: ReconcileResult, aura, frame_id: str, *, mechanism: str = 'remove',
|
|
296
|
+
budget=None, wait_on_budget: bool = True, max_wait_seconds: float = 3600.0,
|
|
297
|
+
clock=get_utc_now, sleep=time.sleep, throttle_seconds: float = WRITE_THROTTLE_SECONDS,
|
|
298
|
+
batch_size: int = WRITE_BATCH_SIZE,
|
|
299
|
+
candidate_limit: int = RECONCILE_PROBE_CANDIDATE_LIMIT,
|
|
300
|
+
progress=lambda *args: None) -> ReconcileResult:
|
|
301
|
+
"""This module's ONLY mutating function (D-16) -- the sole place that
|
|
302
|
+
calls a removal primitive against a live frame.
|
|
303
|
+
|
|
304
|
+
Operates on `result.stuck` and NOTHING else -- every other classification
|
|
305
|
+
bucket `find_placeholders` can populate is structurally unreachable from
|
|
306
|
+
this function's body, which is the enforcement of D-15's "never a
|
|
307
|
+
removal candidate" rule: a young or unresolvable-age row simply cannot
|
|
308
|
+
reach a removal primitive through this code path, independent of any
|
|
309
|
+
caller discipline.
|
|
310
|
+
|
|
311
|
+
Raises a `ValueError` (D-16) when `len(result.stuck)` exceeds
|
|
312
|
+
`candidate_limit` -- no removal mechanism is confirmed to work on these
|
|
313
|
+
rows yet, so the first live use of this path is a bounded, time-boxed
|
|
314
|
+
probe, not a bulk operation. Pass a higher `candidate_limit` to
|
|
315
|
+
override.
|
|
316
|
+
|
|
317
|
+
:param result: A `ReconcileResult` from `find_placeholders`. Only
|
|
318
|
+
`result.stuck` is read; `result.removed`/`result.failed` are
|
|
319
|
+
populated in place and the same object is returned.
|
|
320
|
+
:param aura: An authenticated `Aura` instance.
|
|
321
|
+
:param frame_id: The frame `result.stuck`'s rows belong to.
|
|
322
|
+
:param mechanism: Which primitive to attempt -- `'remove'`
|
|
323
|
+
(`FrameApi.remove_asset`, batch), `'hard-delete'`
|
|
324
|
+
(`AssetApi.delete_asset`, per-asset, irreversible and account-wide),
|
|
325
|
+
or `'complete'` (not yet implemented, see `_complete_placeholder`).
|
|
326
|
+
:param budget: Optional `pushframe.ratelimit.WriteBudget` gating each
|
|
327
|
+
chunk exactly like `execute_plan` -- the account-wide budget,
|
|
328
|
+
shared with `sync`/`push`. `None` (the default) skips every
|
|
329
|
+
budget-related touch point, a true byte-for-byte no-op.
|
|
330
|
+
:param throttle_seconds: Seconds to pause before each write network
|
|
331
|
+
call, mirroring `execute_plan`'s `throttle_seconds`. 0 disables.
|
|
332
|
+
:param batch_size: Maximum number of candidates per removal-primitive
|
|
333
|
+
call, mirroring `execute_plan`'s `batch_size`.
|
|
334
|
+
:param candidate_limit: See `RECONCILE_PROBE_CANDIDATE_LIMIT`.
|
|
335
|
+
:param progress: Optional reporter called once per resolved candidate as
|
|
336
|
+
`progress('reconcile', asset_id, ok)`. Defaults to a no-op.
|
|
337
|
+
:return: The same `ReconcileResult`, with `removed`/`failed` populated.
|
|
338
|
+
|
|
339
|
+
Raises `RateLimitError` (from the client layer) WITHOUT catching it,
|
|
340
|
+
mirroring `execute_plan`'s removal loop: a 429/475 throttle or lockout
|
|
341
|
+
aborts the whole batch immediately rather than being recorded as one of
|
|
342
|
+
N per-item failures.
|
|
343
|
+
"""
|
|
344
|
+
candidates = result.stuck
|
|
345
|
+
if len(candidates) > candidate_limit:
|
|
346
|
+
raise ValueError(
|
|
347
|
+
f'{len(candidates)} stuck placeholder row(s) exceeds RECONCILE_PROBE_CANDIDATE_LIMIT '
|
|
348
|
+
f'({candidate_limit}) -- no removal mechanism is yet confirmed to work on these rows '
|
|
349
|
+
f'(D-16), so this refuses to act on a large batch at once. Pass a higher '
|
|
350
|
+
f'candidate_limit to override.'
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def throttle() -> None:
|
|
354
|
+
if throttle_seconds > 0:
|
|
355
|
+
sleep(throttle_seconds)
|
|
356
|
+
|
|
357
|
+
for chunk in _chunked(candidates, batch_size):
|
|
358
|
+
if budget is not None:
|
|
359
|
+
budget.acquire(_RECONCILE_REQUEST_COST[mechanism](chunk), wait=wait_on_budget,
|
|
360
|
+
max_wait=max_wait_seconds, now=clock(), sleep=sleep)
|
|
361
|
+
try:
|
|
362
|
+
throttle()
|
|
363
|
+
_RECONCILE_PRIMITIVE[mechanism](aura, frame_id, chunk)
|
|
364
|
+
for asset in chunk:
|
|
365
|
+
result.removed.append(asset.id)
|
|
366
|
+
progress('reconcile', asset.id, True)
|
|
367
|
+
except RateLimitError:
|
|
368
|
+
if budget is not None:
|
|
369
|
+
budget.reconcile_tripped(clock())
|
|
370
|
+
budget.save()
|
|
371
|
+
raise
|
|
372
|
+
except Exception as e:
|
|
373
|
+
# remove_asset/delete_asset raising attributes the WHOLE chunk
|
|
374
|
+
# as failed -- there is no per-item signal to fall back on
|
|
375
|
+
# (remove_asset returns only a count; a mid-loop delete_asset
|
|
376
|
+
# failure aborts the remaining per-asset calls in this chunk).
|
|
377
|
+
for asset in chunk:
|
|
378
|
+
result.failed.append((asset.id, str(e)))
|
|
379
|
+
progress('reconcile', asset.id, False)
|
|
380
|
+
|
|
381
|
+
if budget is not None:
|
|
382
|
+
budget.save()
|
|
383
|
+
|
|
384
|
+
return result
|