Skip to content

Commit b46f046

Browse files
committed
fix: prevent silent page loss in store_entity parallel upload
Three linked defects caused store_entity() to drop a page during batch uploads while reporting success. Fix A (wtsite.py, WtPage.edit): the retry loop returned None after exhausting all attempts, discarding the last exception. It now raises a RuntimeError chained from the underlying error so callers see the failure. Fix B1 (wtsite.py): parallel uploads share one mwclient session. Guard the destructive session mutations (_clear_cookies, _relogin, and edit()'s token-refresh recovery) with a per-WtSite RLock, and iterate a snapshot of the cookie jar in _clear_cookies. A lazy _get_session_lock() accessor keeps WtSite instances built via __new__ (tests) working. Fix C (util.py, core.py): add return_exceptions to parallelize() (captures per-task exceptions instead of aborting the batch on the first). store_entity now collects per-entity outcomes for both serial and parallel paths, adds StoreEntityResult.failed, and raises OSW.StoreEntityPartialError carrying the partial result (stored + failed) on any failure. Adds offline unit tests for all three fixes. (cherry picked from commit 280838c)
1 parent c826e92 commit b46f046

6 files changed

Lines changed: 428 additions & 37 deletions

File tree

‎src/osw/core.py‎

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1593,11 +1593,34 @@ class StoreEntityResult(OswBaseModel):
15931593
change_id: str
15941594
"""The ID of the change"""
15951595
pages: Dict[str, WtPage]
1596-
"""The pages that have been stored"""
1596+
"""The pages that have been successfully stored, keyed by full page title.
1597+
On partial failure this contains only the successfully-stored pages."""
1598+
failed: Dict[str, Exception] = {}
1599+
"""Entities that could not be stored, keyed by full page title and mapped to
1600+
the exception that caused the failure. Empty on full success."""
15971601

15981602
class Config:
15991603
arbitrary_types_allowed = True
16001604

1605+
class StoreEntityPartialError(Exception):
1606+
"""Raised by store_entity() when one or more entities could not be stored.
1607+
1608+
Carries the partial ``StoreEntityResult`` so callers can learn exactly which
1609+
entities were written (``stored`` / ``result.pages``) and which failed
1610+
(``failed`` / ``result.failed``) without a separate existence query.
1611+
"""
1612+
1613+
def __init__(self, result: "OSW.StoreEntityResult"):
1614+
self.result = result
1615+
self.stored = list(result.pages.keys())
1616+
self.failed = result.failed
1617+
total = len(result.pages) + len(result.failed)
1618+
failed_titles = ", ".join(result.failed.keys())
1619+
super().__init__(
1620+
f"store_entity failed for {len(result.failed)} of {total} "
1621+
f"entities: {failed_titles}"
1622+
)
1623+
16011624
def store_entity(
16021625
self, param: Union[StoreEntityParam, OswBaseModel, List[OswBaseModel]]
16031626
) -> StoreEntityResult:
@@ -1790,27 +1813,63 @@ class UploadObject(BaseModel):
17901813
upload_index += 1
17911814

17921815
def handle_upload_object_(upload_object: UploadObject) -> None:
1816+
# Let exceptions propagate: the caller collects them per entity below,
1817+
# so a single failure neither aborts the batch nor is silently
1818+
# swallowed (store_entity_ only records created_pages on success).
1819+
store_entity_(
1820+
upload_object.entity,
1821+
upload_object.namespace,
1822+
upload_object.index,
1823+
upload_object.overwrite_class_param,
1824+
)
1825+
1826+
def failure_title_(upload_object: UploadObject) -> str:
1827+
"""Best-effort full page title of a failed entity, for error reporting."""
17931828
try:
1794-
store_entity_(
1795-
upload_object.entity,
1796-
upload_object.namespace,
1797-
upload_object.index,
1798-
upload_object.overwrite_class_param,
1829+
namespace = upload_object.namespace or get_namespace(
1830+
upload_object.entity
1831+
)
1832+
return f"{namespace}:{get_title(upload_object.entity)}"
1833+
except Exception:
1834+
return (
1835+
getattr(upload_object.entity, "name", None)
1836+
or getattr(upload_object.entity, "uuid", None)
1837+
or "unknown"
17991838
)
1800-
except Exception as e:
1801-
entity_name = getattr(upload_object.entity, "name", None) or "unknown"
1802-
_logger.error(f"Error storing entity '{entity_name}': {e}")
18031839

18041840
if param.parallel:
1805-
_ = parallelize(
1806-
handle_upload_object_, upload_object_list, flush_at_end=param.debug
1841+
# return_exceptions=True keeps results aligned with upload_object_list
1842+
# and lets every entity be attempted even if some fail.
1843+
results = parallelize(
1844+
handle_upload_object_,
1845+
upload_object_list,
1846+
flush_at_end=param.debug,
1847+
return_exceptions=True,
18071848
)
18081849
else:
1809-
_ = [
1810-
handle_upload_object_(upload_object)
1811-
for upload_object in upload_object_list
1812-
]
1813-
return OSW.StoreEntityResult(change_id=param.change_id, pages=created_pages)
1850+
results = []
1851+
for upload_object in upload_object_list:
1852+
try:
1853+
handle_upload_object_(upload_object)
1854+
results.append(None)
1855+
except Exception as e: # noqa: BLE001 - collected below
1856+
results.append(e)
1857+
1858+
failed: Dict[str, Exception] = {}
1859+
for upload_object, result in zip(upload_object_list, results):
1860+
if isinstance(result, Exception):
1861+
title = failure_title_(upload_object)
1862+
_logger.error(f"Error storing entity '{title}': {result}")
1863+
failed[title] = result
1864+
1865+
store_result = OSW.StoreEntityResult(
1866+
change_id=param.change_id, pages=created_pages, failed=failed
1867+
)
1868+
if failed:
1869+
# Surface partial/total failure so callers cannot mistake a dropped
1870+
# page for a success. The result (successes + failures) rides along.
1871+
raise OSW.StoreEntityPartialError(store_result)
1872+
return store_result
18141873

18151874
class DeleteEntityParam(OswBaseModel):
18161875
entities: Union[OswBaseModel, List[OswBaseModel]]

‎src/osw/utils/util.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ def parallelize(
274274
iterable: Iterable,
275275
flush_at_end: bool = False,
276276
progress_bar: bool = True,
277+
return_exceptions: bool = False,
277278
**kwargs,
278279
):
279280
"""A function to parallelize tasks with a progress bar and a message buffer.
@@ -289,6 +290,11 @@ def parallelize(
289290
execution.
290291
progress_bar:
291292
If True, a progress bar will be displayed.
293+
return_exceptions:
294+
If True, exceptions raised by ``func`` are returned in the result list at the
295+
position of the corresponding item (instead of aborting the whole batch on the
296+
first failure). Mirrors ``asyncio.gather(return_exceptions=...)``. Results stay
297+
aligned with the input order, so callers can zip them back to ``iterable``.
292298
kwargs:
293299
Keyword arguments to be passed to the function.z
294300
"""
@@ -324,6 +330,18 @@ async def _run_tasks():
324330
print(
325331
f"Performing parallel execution of {func.__name__} ({len(tasks)} tasks)."
326332
)
333+
if return_exceptions:
334+
# tqdm.gather() re-raises the first exception (it has no
335+
# return_exceptions kwarg), which would abandon the remaining
336+
# results. Capture each task's exception instead so the batch
337+
# completes and results stay aligned with the input order.
338+
async def _capture(task):
339+
try:
340+
return await task
341+
except Exception as exc: # noqa: BLE001 - returned to caller
342+
return exc
343+
344+
tasks = [_capture(task) for task in tasks]
327345
res = await tqdm.gather(
328346
*tasks
329347
) # like asyncio.gather, but with a progress bar

‎src/osw/wtsite.py‎

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import os
77
import shutil
8+
import threading
89
import urllib
910
import warnings
1011
import xml.etree.ElementTree as et
@@ -88,6 +89,13 @@ def __init__(self, config: Union[WtSiteConfig, WtSiteLegacyConfig]):
8889
8990
"""
9091

92+
# Serializes destructive mutations of the shared mwclient session
93+
# (cookie jar / tokens). Parallel uploads share one requests.Session, so
94+
# concurrent clears/re-logins would otherwise corrupt each other's state.
95+
# RLock (not Lock) because the edit() retry path may call _relogin() while
96+
# already holding this lock.
97+
self._session_lock = threading.RLock()
98+
9199
scheme = "https"
92100

93101
# Store credentials for potential re-login on session timeout
@@ -153,6 +161,20 @@ def __init__(self, config: Union[WtSiteConfig, WtSiteLegacyConfig]):
153161
self._page_cache = {}
154162
self._cache_enabled = False
155163

164+
def _get_session_lock(self) -> threading.RLock:
165+
"""Return the session lock, lazily creating it if absent.
166+
167+
In normal use the lock is created in ``__init__`` before any worker
168+
threads exist. This fallback keeps WtSite instances that bypassed
169+
``__init__`` (e.g. tests using ``WtSite.__new__``) working; those are
170+
single-threaded at construction, so the lazy creation is race-free.
171+
"""
172+
lock = getattr(self, "_session_lock", None)
173+
if lock is None:
174+
lock = threading.RLock()
175+
self._session_lock = lock
176+
return lock
177+
156178
def _relogin(self):
157179
"""Re-login to the wiki site using stored credentials.
158180
@@ -174,9 +196,12 @@ def _relogin(self):
174196
# Stale session cookies cause MediaWiki to abort the login flow with
175197
# "Unable to continue login. Your session most likely timed out."
176198
# Clear client-side session state so login starts from a clean slate.
177-
self._site.connection.cookies.clear()
178-
self._site.tokens.clear()
179-
self._site.login(username=cred.username, password=cred.password)
199+
# Hold the session lock so in-flight parallel uploads cannot read the
200+
# cookie jar / tokens while they are being wiped and rebuilt.
201+
with self._get_session_lock():
202+
self._site.connection.cookies.clear()
203+
self._site.tokens.clear()
204+
self._site.login(username=cred.username, password=cred.password)
180205
else:
181206
raise RuntimeError(
182207
"Re-login is only supported for username/password credentials."
@@ -457,11 +482,14 @@ def clear_cache(self):
457482

458483
def _clear_cookies(self):
459484
# see https://github.com/mwclient/mwclient/issues/221
460-
for cookie in self._site.connection.cookies:
461-
if "PostEditRevision" in cookie.name:
462-
self._site.connection.cookies.clear(
463-
cookie.domain, cookie.path, cookie.name
464-
)
485+
# Iterate a snapshot (list(...)) so mutating the jar mid-loop is safe, and
486+
# hold the session lock so a peer thread cannot clear/re-login concurrently.
487+
with self._get_session_lock():
488+
for cookie in list(self._site.connection.cookies):
489+
if "PostEditRevision" in cookie.name:
490+
self._site.connection.cookies.clear(
491+
cookie.domain, cookie.path, cookie.name
492+
)
465493

466494
class SearchParam(wt.SearchParam):
467495
pass
@@ -1718,26 +1746,42 @@ def edit(self, comment: str = None, mode="action-multislot", bot_edit: bool = Tr
17181746
mode:
17191747
(optional) single API call ('action-multislot') or multiple (
17201748
'action-singleslot'), by default 'action-multislot' (faster)
1749+
1750+
Raises
1751+
------
1752+
RuntimeError
1753+
if the edit still fails after ``max_retry`` attempts. The last
1754+
underlying exception is chained via ``__cause__``. Previously this
1755+
method returned None on exhaustion, silently discarding the failure.
17211756
"""
1722-
retry = 0
17231757
max_retry = 5
1724-
while retry < max_retry:
1758+
last_exc: Optional[Exception] = None
1759+
for attempt in range(max_retry):
17251760
try:
17261761
return self._edit(comment, mode, bot_edit)
1727-
except Exception as e:
1728-
if retry < max_retry:
1729-
retry += 1
1730-
print(f"Page edit failed: {e}. Retry ({retry}/{max_retry})")
1762+
except Exception as e: # noqa: BLE001 - re-raised below after retries
1763+
last_exc = e
1764+
print(f"Page edit failed: {e}. Retry ({attempt + 1}/{max_retry})")
1765+
if attempt + 1 < max_retry:
1766+
# Attempt to recover the shared session before retrying.
1767+
# Guard the whole block: a recovery failure must never mask
1768+
# the original edit error we intend to re-raise below.
17311769
try:
1732-
# refresh token for longer running processes
1733-
self.wtSite._site.get_token("csrf", force=True)
1770+
# Serialize session mutations so a peer thread's request
1771+
# cannot read a half-cleared cookie jar / token set.
1772+
with self.wtSite._get_session_lock():
1773+
try:
1774+
# refresh token for longer running processes
1775+
self.wtSite._site.get_token("csrf", force=True)
1776+
except Exception:
1777+
# token refresh failed, attempt full re-login
1778+
self.wtSite._relogin()
17341779
except Exception:
1735-
# token refresh failed, attempt full re-login
1736-
try:
1737-
self.wtSite._relogin()
1738-
except Exception:
1739-
pass # re-login failed, will retry anyway
1780+
pass # recovery failed, will retry / re-raise anyway
17401781
sleep(5)
1782+
raise RuntimeError(
1783+
f"Page edit for '{self.title}' failed after {max_retry} attempts"
1784+
) from last_exc
17411785

17421786
def _edit(
17431787
self, comment: str = None, mode="action-multislot", bot_edit: bool = True

‎tests/test_store_entity_failure.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Unit tests for store_entity() per-entity failure reporting (Fix C).
2+
3+
These run fully offline: WtPage.init and the overwrite policy are stubbed so no
4+
network is required, and WtPage.edit is replaced with a controllable stub that
5+
fails for selected page titles.
6+
"""
7+
8+
import pytest
9+
10+
import osw.model.entity as model
11+
from osw.core import OSW
12+
from osw.utils.wiki import get_namespace, get_title
13+
from osw.wtsite import WtPage
14+
15+
16+
def _title(entity):
17+
return f"{get_namespace(entity)}:{get_title(entity)}"
18+
19+
20+
@pytest.fixture
21+
def offline_osw(monkeypatch):
22+
# no network when a WtPage is constructed with do_init=True; mimic the
23+
# do_init=False branch of WtPage.__init__ which sets .exists
24+
monkeypatch.setattr(WtPage, "init", lambda self: setattr(self, "exists", False))
25+
# bypass the overwrite policy: return the page that was built for the entity
26+
monkeypatch.setattr(
27+
OSW, "_apply_overwrite_policy", staticmethod(lambda param: param.page)
28+
)
29+
return OSW.construct(site=object())
30+
31+
32+
def _install_edit(monkeypatch, failing_titles):
33+
def fake_edit(self, *args, **kwargs):
34+
if self.title in failing_titles:
35+
raise RuntimeError(f"edit failed for {self.title}")
36+
return None
37+
38+
monkeypatch.setattr(WtPage, "edit", fake_edit)
39+
40+
41+
def test_store_entity_serial_single_failure_raises(offline_osw, monkeypatch):
42+
item = model.Item(label=[model.Label(text="Solo")])
43+
title = _title(item)
44+
_install_edit(monkeypatch, {title})
45+
46+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
47+
offline_osw.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False))
48+
49+
err = exc_info.value
50+
assert title in err.failed
51+
assert isinstance(err.failed[title], RuntimeError)
52+
# the dropped page must NOT be reported as stored
53+
assert title not in err.result.pages
54+
assert err.stored == []
55+
56+
57+
def test_store_entity_parallel_middle_failure_reports_all(offline_osw, monkeypatch):
58+
items = [model.Item(label=[model.Label(text=f"Item{i}")]) for i in range(3)]
59+
titles = [_title(it) for it in items]
60+
failing = titles[1]
61+
_install_edit(monkeypatch, {failing})
62+
63+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
64+
offline_osw.store_entity(OSW.StoreEntityParam(entities=items, parallel=True))
65+
66+
err = exc_info.value
67+
# the two good entities are recorded as stored (the first exception did not
68+
# abandon the remaining tasks) ...
69+
assert set(err.result.pages.keys()) == {titles[0], titles[2]}
70+
assert set(err.stored) == {titles[0], titles[2]}
71+
# ... and the failing one is reported instead of being silently dropped
72+
assert set(err.failed.keys()) == {failing}
73+
assert isinstance(err.failed[failing], RuntimeError)
74+
75+
76+
def test_store_entity_all_success_returns_result(offline_osw, monkeypatch):
77+
items = [model.Item(label=[model.Label(text=f"Ok{i}")]) for i in range(3)]
78+
titles = [_title(it) for it in items]
79+
_install_edit(monkeypatch, set()) # nothing fails
80+
81+
result = offline_osw.store_entity(
82+
OSW.StoreEntityParam(entities=items, parallel=True)
83+
)
84+
85+
assert set(result.pages.keys()) == set(titles)
86+
assert result.failed == {}
87+
88+
89+
def test_store_entity_serial_all_success_returns_result(offline_osw, monkeypatch):
90+
items = [model.Item(label=[model.Label(text=f"Ser{i}")]) for i in range(2)]
91+
titles = [_title(it) for it in items]
92+
_install_edit(monkeypatch, set())
93+
94+
result = offline_osw.store_entity(
95+
OSW.StoreEntityParam(entities=items, parallel=False)
96+
)
97+
98+
assert set(result.pages.keys()) == set(titles)
99+
assert result.failed == {}

0 commit comments

Comments
 (0)