11from dataclasses import asdict
22
33from db import db
4+ from flask import current_app
45from journalist_app import utils
56from journalist_app .api2 .shared import json_version , mark_source_deleted , save_reply
67from journalist_app .api2 .types import (
@@ -35,13 +36,17 @@ class EventHandler:
3536 `journalist_api2.types.EVENT_DATA_TYPES`;
3637
3738 3. define the handler as a static method `handle_thing_done(event: Event)`
38- in this class
39+ in this class; and
3940
4041 4. explicitly register `{"thing_done": self.handle_thing_done}` inside
4142 `EventHandler.process()`.
4243
4344 This is belt-and-suspenders for ensuring that only the intended methods are
4445 exposed as callable event handlers.
46+
47+ To preserve transaction separation between events, handlers MUST return with
48+ a clean SQLAlchemy session: in other words, having either successfully
49+ committed or rolled back all of their changes.
4550 """
4651
4752 def __init__ (self , session : Session , redis : Redis ) -> None :
@@ -59,7 +64,7 @@ def process(self, event: Event, minor: int) -> EventResult:
5964 """The per-event entry-point for handling a single event."""
6065
6166 try :
62- if self .has_progress (event ):
67+ if self .is_duplicate (event ):
6368 return EventResult (
6469 event_id = event .id ,
6570 status = (EventStatusCode .AlreadyReported , None ),
@@ -83,35 +88,45 @@ def process(self, event: Event, minor: int) -> EventResult:
8388 ),
8489 )
8590
86- self .mark_progress (event ) # prevent races
87- result = handler (event , minor )
88- self .mark_progress (event , result .status [0 ]) # enforce idempotence
91+ try :
92+ result = handler (event , minor )
93+
94+ # Enforce "handlers MUST return with a clean SQLAlchemy session" above:
95+ if db .session .dirty or db .session .new or db .session .deleted :
96+ raise RuntimeError (f"{ handler } returned with a pending database transaction" )
97+
98+ # Catch anything not handled by the handler:
99+ except Exception :
100+ current_app .logger .error (f"unhandled exception in handler for { event } " , exc_info = True )
101+ db .session .rollback ()
102+ result = EventResult (
103+ event .id , (EventStatusCode .InternalServerError , "failed to process event" )
104+ )
105+
106+ self .record_status (event , result .status [0 ])
89107 return result
90108
91109 def idempotence_key (self , event : Event ) -> str :
92110 return f"{ REDIS_EVENT_PREFIX } /{ self ._session .user .uuid } /{ event .id } "
93111
94- def has_progress (self , event : Event ) -> EventStatusCode :
95- return self ._redis .get (self .idempotence_key (event ))
96-
97- def mark_progress (
98- self , event : Event , status : EventStatusCode = EventStatusCode .Processing
99- ) -> None :
100- """
101- If `status` is a non-error code, mark it as the progress of `event`, to
102- be returned later as "Already Reported".
103-
104- If `status` is an error code, clear it, since `event` MAY be resubmitted
105- later.
106- """
107- if status >= EventStatusCode .BadRequest :
108- self ._redis .delete (self .idempotence_key (event ))
109- else :
112+ def is_duplicate (self , event : Event ) -> bool :
113+ """Returns `True` if this event is already registered (i.e., a replay)."""
114+ return (
110115 self ._redis .set (
111116 self .idempotence_key (event ),
112- status ,
117+ EventStatusCode . Processing ,
113118 ex = IDEMPOTENCE_PERIOD ,
119+ nx = True ,
114120 )
121+ is None
122+ )
123+
124+ def record_status (self , event : Event , status : EventStatusCode ) -> None :
125+ """Record the event's final status for idempotence, or clear on error to permit retry."""
126+ if status >= EventStatusCode .BadRequest :
127+ self ._redis .delete (self .idempotence_key (event ))
128+ else :
129+ self ._redis .set (self .idempotence_key (event ), status , ex = IDEMPOTENCE_PERIOD )
115130
116131 @staticmethod
117132 def handle_item_deleted (event : Event , minor : int ) -> EventResult :
0 commit comments