Skip to content

Commit 1ca5516

Browse files
authored
fix: auto-reject PROGRAM messages with non-dict metadata (#1137)
Some PROGRAM messages slipped past validation while ExecutableContent.metadata accepted lists. The current validator requires a dict, so reading those rows fails parsed_content and surfaces as 500s on GET /messages/<hash>. Move them to REJECTED at startup so the API renders them like nodes that rejected them in the first place. The transition logic also lives behind a deployment/scripts helper for ad-hoc cleanups when waiting for a restart is not an option.
1 parent 1275157 commit 1ca5516

3 files changed

Lines changed: 532 additions & 3 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
#!/usr/bin/env python3
2+
"""Mark processed messages as rejected.
3+
4+
Use when a message was accepted under permissive validation rules that have
5+
since become stricter (ex: BaseExecutableContent.metadata now requires a dict
6+
and rejects lists, but some nodes accepted such messages historically). The
7+
API returns 500 on those messages because parsed_content access raises; moving
8+
them to the rejected state matches what nodes that rejected them in the first
9+
place expose to clients.
10+
11+
The actual rejection logic lives in `aleph.repair.mark_processed_message_as_rejected`
12+
and is also wired into `repair_node`, which runs at startup. This script
13+
exists for ad-hoc cleanups when you have a known list of hashes and don't
14+
want to wait for the next restart.
15+
16+
Runs as a dry-run by default. Pass --commit to actually persist changes.
17+
Hashes can be provided via repeated --hash flags or via --hashes-file (one
18+
hash per line, lines starting with # are skipped).
19+
"""
20+
21+
import argparse
22+
import logging
23+
import sys
24+
from pathlib import Path
25+
from typing import Iterable, List
26+
27+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "src"))
28+
29+
from sqlalchemy import select # noqa: E402
30+
31+
import aleph.config # noqa: E402
32+
from aleph.db.connection import make_engine, make_session_factory # noqa: E402
33+
from aleph.db.models.messages import MessageDb # noqa: E402
34+
from aleph.repair import mark_processed_message_as_rejected # noqa: E402
35+
from aleph.types.db_session import DbSession # noqa: E402
36+
from aleph.types.message_status import ErrorCode, MessageStatus # noqa: E402
37+
38+
LOGGER = logging.getLogger("reject_processed_messages")
39+
40+
41+
def reject_processed_message(
42+
session: DbSession,
43+
item_hash: str,
44+
error_code: ErrorCode,
45+
reason: str,
46+
) -> bool:
47+
message = session.execute(
48+
select(MessageDb).where(MessageDb.item_hash == item_hash)
49+
).scalar_one_or_none()
50+
51+
if message is None:
52+
LOGGER.warning("%s: not found in messages, skipping", item_hash)
53+
return False
54+
55+
if message.status_value == MessageStatus.REJECTED:
56+
LOGGER.info("%s: already rejected, skipping", item_hash)
57+
return False
58+
59+
if message.status_value != MessageStatus.PROCESSED:
60+
LOGGER.warning(
61+
"%s: unexpected status %s, skipping",
62+
item_hash,
63+
message.status_value,
64+
)
65+
return False
66+
67+
message_type = message.type
68+
mark_processed_message_as_rejected(
69+
session=session,
70+
message=message,
71+
error_code=error_code,
72+
reason=reason,
73+
)
74+
75+
LOGGER.info(
76+
"%s: rejected (type=%s, error_code=%s)",
77+
item_hash,
78+
message_type,
79+
error_code.name,
80+
)
81+
return True
82+
83+
84+
def _read_hashes(args: argparse.Namespace) -> List[str]:
85+
hashes: List[str] = list(args.hash or [])
86+
if args.hashes_file:
87+
with open(args.hashes_file, encoding="utf-8") as f:
88+
for raw in f:
89+
line = raw.strip()
90+
if line and not line.startswith("#"):
91+
hashes.append(line)
92+
return hashes
93+
94+
95+
def _parse_error_code(value: str) -> ErrorCode:
96+
if value.lstrip("-").isdigit():
97+
return ErrorCode(int(value))
98+
return ErrorCode[value]
99+
100+
101+
def main(argv: Iterable[str]) -> int:
102+
parser = argparse.ArgumentParser(
103+
description=__doc__,
104+
formatter_class=argparse.RawDescriptionHelpFormatter,
105+
)
106+
parser.add_argument(
107+
"-c", "--config", dest="config_file", default=None, help="Config file path"
108+
)
109+
parser.add_argument(
110+
"--hash",
111+
action="append",
112+
default=[],
113+
help="Message item hash to reject. Pass multiple times for several hashes.",
114+
)
115+
parser.add_argument(
116+
"--hashes-file",
117+
default=None,
118+
help="Path to a file with one item hash per line.",
119+
)
120+
parser.add_argument(
121+
"--error-code",
122+
type=_parse_error_code,
123+
default=ErrorCode.INVALID_FORMAT,
124+
help="ErrorCode to record (name or integer, default: INVALID_FORMAT).",
125+
)
126+
parser.add_argument(
127+
"--reason",
128+
default=(
129+
"Marked rejected by reject_processed_messages.py: content fails "
130+
"validation under current rules."
131+
),
132+
help="Free-text reason stored on the rejected_messages row.",
133+
)
134+
parser.add_argument(
135+
"--commit",
136+
action="store_true",
137+
help="Persist changes. Without this flag the script runs as a dry-run.",
138+
)
139+
parser.add_argument(
140+
"-v", "--verbose", action="store_true", help="Enable debug logging."
141+
)
142+
143+
args = parser.parse_args(list(argv))
144+
145+
logging.basicConfig(
146+
level=logging.DEBUG if args.verbose else logging.INFO,
147+
format="%(asctime)s [%(levelname)s] %(message)s",
148+
)
149+
150+
hashes = _read_hashes(args)
151+
if not hashes:
152+
parser.error("Provide --hash and/or --hashes-file with at least one hash")
153+
154+
config = aleph.config.app_config
155+
if args.config_file is not None:
156+
config.yaml.load(args.config_file)
157+
158+
engine = make_engine(config=config, application_name="reject-processed-messages")
159+
session_factory = make_session_factory(engine)
160+
161+
changed = 0
162+
skipped = 0
163+
errors = 0
164+
165+
for item_hash in hashes:
166+
with session_factory() as session:
167+
try:
168+
applied = reject_processed_message(
169+
session=session,
170+
item_hash=item_hash,
171+
error_code=args.error_code,
172+
reason=args.reason,
173+
)
174+
except Exception:
175+
LOGGER.exception("%s: failed to reject", item_hash)
176+
session.rollback()
177+
errors += 1
178+
continue
179+
180+
if not applied:
181+
session.rollback()
182+
skipped += 1
183+
continue
184+
185+
if args.commit:
186+
session.commit()
187+
changed += 1
188+
else:
189+
session.rollback()
190+
LOGGER.info("%s: dry-run, rolled back", item_hash)
191+
changed += 1
192+
193+
mode = "commit" if args.commit else "dry-run"
194+
LOGGER.info(
195+
"Done [%s]: %d changed, %d skipped, %d errors",
196+
mode,
197+
changed,
198+
skipped,
199+
errors,
200+
)
201+
return 1 if errors else 0
202+
203+
204+
if __name__ == "__main__":
205+
sys.exit(main(sys.argv[1:]))

src/aleph/repair.py

Lines changed: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,96 @@
11
import logging
2+
from typing import Any, Dict
23

3-
from aleph_message.models import ItemHash
4-
from sqlalchemy import delete, select
4+
from aleph_message.models import ItemHash, MessageType
5+
from sqlalchemy import delete, func, select
56
from sqlalchemy.dialects.postgresql import insert as pg_insert
67

78
from aleph.db.accessors.files import upsert_file
8-
from aleph.db.models import AlephCreditBalanceDb, AlephCreditHistoryDb, StoredFileDb
9+
from aleph.db.accessors.messages import (
10+
make_message_status_upsert_query,
11+
make_upsert_rejected_message_statement,
12+
)
13+
from aleph.db.accessors.vms import delete_vm, delete_vm_updates
14+
from aleph.db.models import (
15+
AlephCreditBalanceDb,
16+
AlephCreditHistoryDb,
17+
MessageDb,
18+
MessageStatusDb,
19+
StoredFileDb,
20+
)
921
from aleph.storage import StorageService
22+
from aleph.toolkit.timestamp import utc_now
1023
from aleph.types.db_session import DbSession, DbSessionFactory
24+
from aleph.types.message_status import ErrorCode, MessageStatus
1125

1226
LOGGER = logging.getLogger(__name__)
1327

1428

29+
def _wire_message_dict(message: MessageDb) -> Dict[str, Any]:
30+
"""Snapshot a ``MessageDb`` row as a JSON-serializable wire-format dict
31+
suitable for the ``rejected_messages.message`` JSONB column."""
32+
data = message.to_dict(exclude=set(MessageDb.DENORMALIZED_COLUMNS))
33+
34+
if data.get("time") is not None:
35+
data["time"] = data["time"].timestamp()
36+
37+
for key in ("chain", "type", "item_type"):
38+
value = data.get(key)
39+
if value is not None and hasattr(value, "value"):
40+
data[key] = value.value
41+
42+
return data
43+
44+
45+
def mark_processed_message_as_rejected(
46+
session: DbSession,
47+
message: MessageDb,
48+
error_code: ErrorCode,
49+
reason: str,
50+
) -> None:
51+
"""Transition a processed message into the REJECTED state.
52+
53+
Mirrors ``mark_pending_message_as_rejected`` for messages that already
54+
cleared the pipeline under permissive rules but are no longer valid under
55+
current ones (ex: ExecutableContent.metadata used to accept lists, now
56+
requires a dict). Cleans up type-specific state (VM rows for
57+
program/instance), snapshots the row into ``rejected_messages``, flips
58+
``message_status`` to REJECTED, and deletes the ``messages`` row. The
59+
trigger keeps ``message_counts`` consistent; FK cascades clean
60+
``message_confirmations`` and ``account_costs``.
61+
62+
Does not commit. Caller is responsible for state checks (in particular,
63+
that ``message.status_value == MessageStatus.PROCESSED``).
64+
"""
65+
snapshot = _wire_message_dict(message)
66+
67+
if message.type in (MessageType.program, MessageType.instance):
68+
delete_vm(session=session, vm_hash=message.item_hash)
69+
_ = list(delete_vm_updates(session=session, vm_hash=message.item_hash))
70+
71+
session.execute(
72+
make_upsert_rejected_message_statement(
73+
item_hash=message.item_hash,
74+
pending_message_dict=snapshot,
75+
error_code=int(error_code),
76+
details={"errors": [reason]},
77+
exc_traceback=reason,
78+
tx_hash=None,
79+
)
80+
)
81+
82+
session.execute(
83+
make_message_status_upsert_query(
84+
item_hash=message.item_hash,
85+
new_status=MessageStatus.REJECTED,
86+
reception_time=utc_now(),
87+
where=MessageStatusDb.status != MessageStatus.REJECTED,
88+
)
89+
)
90+
91+
session.execute(delete(MessageDb).where(MessageDb.item_hash == message.item_hash))
92+
93+
1594
async def _fix_file_sizes(
1695
session: DbSession, storage_service: StorageService, store_files: bool
1796
):
@@ -137,6 +216,68 @@ def _repair_credit_balances(session_factory: DbSessionFactory) -> None:
137216
LOGGER.info("Credit balances repair complete (%d address(es))", len(addresses))
138217

139218

219+
_INVALID_METADATA_REASON = (
220+
"ExecutableContent.metadata must be a dict; legacy rows with a list value "
221+
"no longer parse and surfaced as 500s at the API."
222+
)
223+
224+
225+
def _reject_invalid_program_metadata(session_factory: DbSessionFactory) -> None:
226+
"""Reject PROGRAM messages whose ``content.metadata`` is a JSON array.
227+
228+
aleph-message historically accepted ``ExecutableContent.metadata`` as
229+
either a dict or a list. The current validator requires a dict, so rows
230+
accepted under the old rules trip ``parsed_content`` access and surface as
231+
500s on ``GET /api/v0/messages/<hash>``. Moves them to the rejected state
232+
so the API can render them the same way nodes that rejected them in the
233+
first place do.
234+
235+
Per-message commits so a single bad row does not roll back the rest.
236+
"""
237+
with session_factory() as session:
238+
select_stmt = (
239+
select(MessageDb.item_hash)
240+
.where(MessageDb.type == MessageType.program)
241+
.where(MessageDb.status_value == MessageStatus.PROCESSED)
242+
.where(func.jsonb_typeof(MessageDb.content["metadata"]) == "array")
243+
)
244+
item_hashes = list(session.execute(select_stmt).scalars())
245+
246+
if not item_hashes:
247+
return
248+
249+
LOGGER.info(
250+
"Rejecting %d PROGRAM message(s) with non-dict metadata", len(item_hashes)
251+
)
252+
253+
rejected = 0
254+
for item_hash in item_hashes:
255+
with session_factory() as session:
256+
try:
257+
message = session.execute(
258+
select(MessageDb).where(MessageDb.item_hash == item_hash)
259+
).scalar_one_or_none()
260+
if message is None or message.status_value != MessageStatus.PROCESSED:
261+
continue
262+
mark_processed_message_as_rejected(
263+
session=session,
264+
message=message,
265+
error_code=ErrorCode.INVALID_FORMAT,
266+
reason=_INVALID_METADATA_REASON,
267+
)
268+
session.commit()
269+
rejected += 1
270+
except Exception:
271+
LOGGER.exception("Failed to reject program %s", item_hash)
272+
session.rollback()
273+
274+
LOGGER.info(
275+
"Done: rejected %d / %d PROGRAM message(s) with non-dict metadata",
276+
rejected,
277+
len(item_hashes),
278+
)
279+
280+
140281
async def repair_node(
141282
storage_service: StorageService, session_factory: DbSessionFactory
142283
):
@@ -147,3 +288,6 @@ async def repair_node(
147288

148289
LOGGER.info("Repairing credit balances")
149290
_repair_credit_balances(session_factory)
291+
292+
LOGGER.info("Rejecting PROGRAM messages with invalid metadata")
293+
_reject_invalid_program_metadata(session_factory)

0 commit comments

Comments
 (0)