11import 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
56from sqlalchemy .dialects .postgresql import insert as pg_insert
67
78from 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+ )
921from aleph .storage import StorageService
22+ from aleph .toolkit .timestamp import utc_now
1023from aleph .types .db_session import DbSession , DbSessionFactory
24+ from aleph .types .message_status import ErrorCode , MessageStatus
1125
1226LOGGER = 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+
1594async 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+
140281async 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