Skip to content

Commit 3826233

Browse files
authored
fix(core): verify that store_entity actually created the page
Merge pull request #182 from OpenSemanticLab/fix/175-store-entity-verify
2 parents 848615c + 5756436 commit 3826233

4 files changed

Lines changed: 323 additions & 1 deletion

File tree

‎src/osw/core.py‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import warnings
1010
from copy import deepcopy
1111
from enum import Enum
12+
from time import sleep
1213
from typing import Any, Dict, List, Optional, Type, Union, overload
1314
from uuid import UUID, uuid4
1415
from warnings import warn
@@ -1639,6 +1640,14 @@ class StoreEntityParam(OswBaseModel):
16391640
offline: Optional[bool] = False
16401641
"""If set to True, the processed entities are not upload but only returned as WtPages.
16411642
Can be used to create WtPage objects from entities without uploading them."""
1643+
verify_write: Optional[bool] = True
1644+
"""If set to True, the existence of every edited page is queried after the
1645+
upload. A page that does not exist afterwards is reported in
1646+
StoreEntityResult.failed instead of StoreEntityResult.pages. This costs one
1647+
additional API request per 50 edited pages, and one further request some
1648+
seconds later if a page is reported as missing. If the query itself fails,
1649+
the pages are reported as stored and an error is logged. Has no effect if
1650+
'offline' is True."""
16421651
_overwrite_per_class: Dict[str, Dict[str, OSW.OverwriteClassParam]] = (
16431652
PrivateAttr()
16441653
)
@@ -1717,6 +1726,73 @@ def __init__(self, result: OSW.StoreEntityResult):
17171726
f"entities: {failed_titles}"
17181727
)
17191728

1729+
class PageNotCreatedError(Exception):
1730+
"""Raised for a page that does not exist after store_entity() edited it.
1731+
1732+
The edit was sent and no exception was raised, but the page is absent when
1733+
the wiki is asked afterwards. A form or template driven creation step on
1734+
the category can reject the content server-side without reporting an error
1735+
to the API client.
1736+
"""
1737+
1738+
def __init__(self, title: str):
1739+
self.title = title
1740+
super().__init__(
1741+
f"Page '{title}' does not exist after the edit. The write was "
1742+
f"rejected by the wiki without an error response."
1743+
)
1744+
1745+
def _get_missing_page_titles(
1746+
self, titles: List[str], confirm_delay_s: int = 5
1747+
) -> List[str]:
1748+
"""Returns those of the given page titles that do not exist on the wiki.
1749+
1750+
A title the wiki reports as missing is queried a second time after
1751+
confirm_delay_s seconds. A read can be answered by a database replica that
1752+
does not have the write yet, and a title that is still absent seconds later
1753+
is not explained by that lag.
1754+
1755+
Parameters
1756+
----------
1757+
titles:
1758+
Full page titles to check.
1759+
confirm_delay_s:
1760+
Seconds to wait before the second query. Set to 0 to query only once.
1761+
"""
1762+
missing = self._query_missing_page_titles(titles)
1763+
if missing and confirm_delay_s:
1764+
sleep(confirm_delay_s)
1765+
missing = self._query_missing_page_titles(missing)
1766+
return missing
1767+
1768+
def _query_missing_page_titles(self, titles: List[str]) -> List[str]:
1769+
"""Asks the wiki once which of the given page titles do not exist.
1770+
1771+
The query goes to the MediaWiki API directly and not through
1772+
WtSite.get_page(), because the page cache would answer with the state from
1773+
before the write.
1774+
1775+
Parameters
1776+
----------
1777+
titles:
1778+
Full page titles to check.
1779+
"""
1780+
missing = []
1781+
batch_size = 50 # maximum number of titles per API query
1782+
for start in range(0, len(titles), batch_size):
1783+
batch = titles[start : start + batch_size]
1784+
result = self.mw_site.api(
1785+
"query", titles="|".join(batch), prop="info", format="json"
1786+
)
1787+
query = result.get("query", {})
1788+
# the API normalizes titles, map them back to what was requested
1789+
normalized = {n["to"]: n["from"] for n in query.get("normalized", [])}
1790+
for page_info in query.get("pages", {}).values():
1791+
if "missing" in page_info:
1792+
title = page_info.get("title")
1793+
missing.append(normalized.get(title, title))
1794+
return missing
1795+
17201796
def store_entity(
17211797
self, param: Union[StoreEntityParam, OswBaseModel, List[OswBaseModel]]
17221798
) -> StoreEntityResult:
@@ -1739,6 +1815,8 @@ def store_entity(
17391815

17401816
max_index = len(param.entities)
17411817
created_pages = {}
1818+
edited_titles = set()
1819+
"""Titles of the pages an edit was sent for, to be verified below."""
17421820

17431821
meta_category_templates = {}
17441822
if param.namespace == "Category":
@@ -1863,6 +1941,7 @@ def store_entity_(
18631941
page.edit(
18641942
param.edit_comment, bot_edit=param.bot_edit
18651943
) # will set page.changed if the content of the page has changed
1944+
edited_titles.add(page.title)
18661945
if not param.offline and page.changed:
18671946
if index is None:
18681947
print(f"Entity stored at '{page.get_url()}'.")
@@ -1922,7 +2001,10 @@ class UploadObject(BaseModel):
19222001
def handle_upload_object_(upload_object: UploadObject) -> None:
19232002
# Let exceptions propagate: the caller collects them per entity below,
19242003
# so a single failure neither aborts the batch nor is silently
1925-
# swallowed (store_entity_ only records created_pages on success).
2004+
# swallowed. store_entity_ records a page in created_pages whenever it
2005+
# reaches its last statement, which only means that nothing raised.
2006+
# Whether the page exists afterwards is checked by the verification
2007+
# step below.
19262008
store_entity_(
19272009
upload_object.entity,
19282010
upload_object.namespace,
@@ -1969,6 +2051,30 @@ def failure_title_(upload_object: UploadObject) -> str:
19692051
_logger.error(f"Error storing entity '{title}': {result}")
19702052
failed[title] = result
19712053

2054+
if param.verify_write and not param.offline and edited_titles:
2055+
# An edit that raised no exception is not proof that the page exists:
2056+
# a form or template driven creation step can reject the content
2057+
# server-side. page.changed is no help either, it is True in that case.
2058+
titles_to_verify = [
2059+
title for title in edited_titles if title in created_pages
2060+
]
2061+
try:
2062+
missing_titles = self._get_missing_page_titles(titles_to_verify)
2063+
except Exception as e:
2064+
# A failed query is no evidence that the writes failed. Report it
2065+
# and keep the pages, instead of discarding everything this call
2066+
# has collected so far.
2067+
missing_titles = []
2068+
_logger.error(
2069+
f"Could not verify {len(titles_to_verify)} stored pages, they "
2070+
f"are reported as stored without being checked: {e}"
2071+
)
2072+
for title in missing_titles:
2073+
error = OSW.PageNotCreatedError(title)
2074+
_logger.error(f"Error storing entity '{title}': {error}")
2075+
failed[title] = error
2076+
del created_pages[title]
2077+
19722078
store_result = OSW.StoreEntityResult(
19732079
change_id=param.change_id, pages=created_pages, failed=failed
19742080
)

‎tests/test_overwrite_policy.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,10 @@ def _stub_page_io(monkeypatch, exists: bool):
355355
)
356356
edited = []
357357
monkeypatch.setattr(WtPage, "edit", lambda self, *a, **k: edited.append(self.title))
358+
# the write verification of store_entity() would query the wiki. These tests
359+
# cover the overwrite policy, so report every edited page as existing. The
360+
# verification itself is covered by test_store_entity_verify.py
361+
monkeypatch.setattr(OSW, "_get_missing_page_titles", lambda self, titles: [])
358362
return edited
359363

360364

‎tests/test_store_entity_failure.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ def offline_osw(monkeypatch):
2626
monkeypatch.setattr(
2727
OSW, "_apply_overwrite_policy", staticmethod(lambda param: param.page)
2828
)
29+
# the write verification would query the wiki. These tests cover the reporting
30+
# of a failed edit, so report every edited page as existing. The verification
31+
# itself is covered by test_store_entity_verify.py
32+
monkeypatch.setattr(OSW, "_get_missing_page_titles", lambda self, titles: [])
2933
return OSW.construct(site=object())
3034

3135

‎tests/test_store_entity_verify.py‎

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Unit tests for the write verification of store_entity().
2+
3+
Regression guard for #175: store_entity() returned normally and listed a page in
4+
created_pages when that page did not exist on the wiki afterwards. An edit that
5+
raises nothing is not proof that the page was created, and page.changed is True
6+
in the failing case as well. store_entity() must now query the edited pages and
7+
report an absent one in result.failed.
8+
9+
These run fully offline: WtPage.init, the overwrite policy and the existence
10+
query are stubbed, so no network is required.
11+
"""
12+
13+
import pytest
14+
15+
import osw.core as core_mod
16+
import osw.model.entity as model
17+
from osw.core import OSW
18+
from osw.utils.wiki import get_namespace, get_title
19+
from osw.wtsite import WtPage
20+
21+
22+
def _title(entity):
23+
return f"{get_namespace(entity)}:{get_title(entity)}"
24+
25+
26+
class _FakeMwSite:
27+
"""Records the API queries and answers them from a set of missing titles.
28+
29+
missing_once holds titles that are reported as missing by the first query
30+
only, which is what a read from a lagging database replica looks like.
31+
"""
32+
33+
def __init__(
34+
self, missing_titles=(), normalized=None, missing_once=(), fails=False
35+
):
36+
self.missing_titles = set(missing_titles)
37+
self.missing_once = set(missing_once)
38+
self.normalized = normalized or {}
39+
self.fails = fails
40+
self.queries = []
41+
42+
def api(self, action, **kwargs):
43+
assert action == "query"
44+
if self.fails:
45+
raise RuntimeError("the API is not reachable")
46+
titles = kwargs["titles"].split("|")
47+
self.queries.append(titles)
48+
missing_now = self.missing_titles | self.missing_once
49+
self.missing_once = set()
50+
pages = {}
51+
for i, title in enumerate(titles):
52+
reported = self.normalized.get(title, title)
53+
if title in missing_now:
54+
pages[str(-(i + 1))] = {"title": reported, "missing": ""}
55+
else:
56+
pages[str(i + 1)] = {"title": reported, "pageid": i + 1}
57+
query = {"pages": pages}
58+
if self.normalized:
59+
query["normalized"] = [
60+
{"from": k, "to": v} for k, v in self.normalized.items()
61+
]
62+
return {"query": query}
63+
64+
65+
class _FakeSite:
66+
def __init__(self, mw_site):
67+
self.mw_site = mw_site
68+
69+
70+
@pytest.fixture
71+
def offline_osw(monkeypatch):
72+
"""An OSW that never touches the network, with a controllable existence query."""
73+
monkeypatch.setattr(WtPage, "init", lambda self: setattr(self, "exists", False))
74+
monkeypatch.setattr(
75+
OSW, "_apply_overwrite_policy", staticmethod(lambda param: param.page)
76+
)
77+
monkeypatch.setattr(WtPage, "edit", lambda self, *a, **kw: None)
78+
# the delay before the confirmation query, not worth waiting for in a test
79+
monkeypatch.setattr(core_mod, "sleep", lambda *a, **kw: None)
80+
81+
def _make(missing_titles=(), normalized=None, missing_once=(), fails=False):
82+
mw_site = _FakeMwSite(missing_titles, normalized, missing_once, fails)
83+
return OSW.construct(site=_FakeSite(mw_site)), mw_site
84+
85+
return _make
86+
87+
88+
def test_absent_page_is_reported_as_failed(offline_osw):
89+
item = model.Item(label=[model.Label(text="Ghost")])
90+
title = _title(item)
91+
osw_obj, _mw_site = offline_osw(missing_titles=[title])
92+
93+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
94+
osw_obj.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False))
95+
96+
err = exc_info.value
97+
assert title in err.failed
98+
assert isinstance(err.failed[title], OSW.PageNotCreatedError)
99+
assert err.failed[title].title == title
100+
assert title not in err.result.pages
101+
assert err.stored == []
102+
103+
104+
def test_present_page_is_reported_as_stored(offline_osw):
105+
item = model.Item(label=[model.Label(text="Real")])
106+
title = _title(item)
107+
osw_obj, mw_site = offline_osw()
108+
109+
result = osw_obj.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False))
110+
111+
assert set(result.pages.keys()) == {title}
112+
assert result.failed == {}
113+
assert mw_site.queries == [[title]]
114+
115+
116+
def test_only_the_absent_page_of_a_batch_is_reported(offline_osw):
117+
items = [model.Item(label=[model.Label(text=f"Batch{i}")]) for i in range(3)]
118+
titles = [_title(it) for it in items]
119+
osw_obj, _mw_site = offline_osw(missing_titles=[titles[1]])
120+
121+
with pytest.raises(OSW.StoreEntityPartialError) as exc_info:
122+
osw_obj.store_entity(OSW.StoreEntityParam(entities=items, parallel=True))
123+
124+
err = exc_info.value
125+
assert set(err.result.pages.keys()) == {titles[0], titles[2]}
126+
assert set(err.failed.keys()) == {titles[1]}
127+
128+
129+
def test_verification_is_skipped_when_disabled(offline_osw):
130+
item = model.Item(label=[model.Label(text="Unchecked")])
131+
title = _title(item)
132+
osw_obj, mw_site = offline_osw(missing_titles=[title])
133+
134+
result = osw_obj.store_entity(
135+
OSW.StoreEntityParam(entities=[item], parallel=False, verify_write=False)
136+
)
137+
138+
assert mw_site.queries == []
139+
assert set(result.pages.keys()) == {title}
140+
141+
142+
def test_verification_is_skipped_offline(offline_osw):
143+
item = model.Item(label=[model.Label(text="Offline")])
144+
title = _title(item)
145+
osw_obj, mw_site = offline_osw(missing_titles=[title])
146+
147+
result = osw_obj.store_entity(
148+
OSW.StoreEntityParam(entities=[item], parallel=False, offline=True)
149+
)
150+
151+
assert mw_site.queries == []
152+
assert set(result.pages.keys()) == {title}
153+
154+
155+
def test_a_missing_page_is_confirmed_by_a_second_query(offline_osw):
156+
item = model.Item(label=[model.Label(text="Ghost")])
157+
title = _title(item)
158+
osw_obj, mw_site = offline_osw(missing_titles=[title])
159+
160+
with pytest.raises(OSW.StoreEntityPartialError):
161+
osw_obj.store_entity(OSW.StoreEntityParam(entities=[item], parallel=False))
162+
163+
assert mw_site.queries == [[title], [title]]
164+
165+
166+
def test_a_page_that_appears_on_the_second_query_is_not_reported(offline_osw):
167+
"""A read answered by a lagging database replica must not fail the store."""
168+
items = [model.Item(label=[model.Label(text=f"Lag{i}")]) for i in range(2)]
169+
titles = [_title(it) for it in items]
170+
osw_obj, mw_site = offline_osw(missing_once=[titles[0]])
171+
172+
result = osw_obj.store_entity(OSW.StoreEntityParam(entities=items, parallel=False))
173+
174+
assert set(result.pages.keys()) == set(titles)
175+
assert result.failed == {}
176+
# the second query asks only for the title the first one reported as missing
177+
assert mw_site.queries[1] == [titles[0]]
178+
179+
180+
def test_a_failing_query_keeps_the_pages_and_does_not_raise(offline_osw):
181+
items = [model.Item(label=[model.Label(text=f"Unverified{i}")]) for i in range(2)]
182+
titles = [_title(it) for it in items]
183+
osw_obj, _mw_site = offline_osw(fails=True)
184+
185+
result = osw_obj.store_entity(OSW.StoreEntityParam(entities=items, parallel=False))
186+
187+
assert set(result.pages.keys()) == set(titles)
188+
assert result.failed == {}
189+
190+
191+
def test_titles_are_queried_in_batches_of_fifty(offline_osw):
192+
osw_obj, mw_site = offline_osw()
193+
titles = [f"Item:OSW{i:04d}" for i in range(120)]
194+
195+
missing = osw_obj._get_missing_page_titles(titles)
196+
197+
assert missing == []
198+
assert [len(batch) for batch in mw_site.queries] == [50, 50, 20]
199+
200+
201+
def test_missing_titles_are_mapped_back_to_the_requested_form(offline_osw):
202+
requested = "Item:OSW_with_underscores"
203+
osw_obj, _mw_site = offline_osw(
204+
missing_titles=[requested],
205+
normalized={requested: "Item:OSW with underscores"},
206+
)
207+
208+
assert osw_obj._get_missing_page_titles([requested]) == [requested]

0 commit comments

Comments
 (0)