7
7
import uuid
8
8
import warnings
9
9
from collections import defaultdict
10
+ from datetime import datetime
10
11
from pathlib import Path
11
12
from typing import Callable , List , Literal , Optional , Protocol , Tuple , Type , Union , cast
12
13
30
31
from .logging import LogLevel , configure_logging
31
32
from .models import (
32
33
DeserializerType ,
34
+ MessageContext ,
35
+ Row ,
33
36
SerializerType ,
34
37
TimestampExtractor ,
35
38
Topic ,
@@ -152,6 +155,7 @@ def __init__(
152
155
topic_create_timeout : float = 60 ,
153
156
processing_guarantee : ProcessingGuarantee = "at-least-once" ,
154
157
max_partition_buffer_size : int = 10000 ,
158
+ heartbeat_interval : float = 0.0 ,
155
159
):
156
160
"""
157
161
:param broker_address: Connection settings for Kafka.
@@ -220,6 +224,12 @@ def __init__(
220
224
It is a soft limit, and the actual number of buffered messages can be up to x2 higher.
221
225
Lower value decreases the memory use, but increases the latency.
222
226
Default - `10000`.
227
+ :param heartbeat_interval: the interval (seconds) at which to send heartbeat messages.
228
+ The heartbeat timing starts counting from application start.
229
+ TODO: Save and respect last heartbeat timestamp.
230
+ The heartbeat is sent for every partition of every topic with registered heartbeat streams.
231
+ If the value is 0, no heartbeat messages will be sent.
232
+ Default - `0.0`.
223
233
224
234
<br><br>***Error Handlers***<br>
225
235
To handle errors, `Application` accepts callbacks triggered when
@@ -371,6 +381,10 @@ def __init__(
371
381
recovery_manager = recovery_manager ,
372
382
)
373
383
384
+ self ._heartbeat_active = heartbeat_interval > 0
385
+ self ._heartbeat_interval = heartbeat_interval
386
+ self ._heartbeat_last_sent = datetime .now ().timestamp ()
387
+
374
388
self ._source_manager = SourceManager ()
375
389
self ._sink_manager = SinkManager ()
376
390
self ._dataframe_registry = DataFrameRegistry ()
@@ -900,6 +914,7 @@ def _run_dataframe(self, sink: Optional[VoidExecutor] = None):
900
914
processing_context = self ._processing_context
901
915
source_manager = self ._source_manager
902
916
process_message = self ._process_message
917
+ process_heartbeat = self ._process_heartbeat
903
918
printer = self ._processing_context .printer
904
919
run_tracker = self ._run_tracker
905
920
consumer = self ._consumer
@@ -912,6 +927,9 @@ def _run_dataframe(self, sink: Optional[VoidExecutor] = None):
912
927
)
913
928
914
929
dataframes_composed = self ._dataframe_registry .compose_all (sink = sink )
930
+ heartbeats_composed = self ._dataframe_registry .compose_heartbeats ()
931
+ if not heartbeats_composed :
932
+ self ._heartbeat_active = False
915
933
916
934
processing_context .init_checkpoint ()
917
935
run_tracker .set_as_running ()
@@ -923,6 +941,7 @@ def _run_dataframe(self, sink: Optional[VoidExecutor] = None):
923
941
run_tracker .timeout_refresh ()
924
942
else :
925
943
process_message (dataframes_composed )
944
+ process_heartbeat (heartbeats_composed )
926
945
processing_context .commit_checkpoint ()
927
946
consumer .resume_backpressured ()
928
947
source_manager .raise_for_error ()
@@ -1006,6 +1025,41 @@ def _process_message(self, dataframe_composed):
1006
1025
if self ._on_message_processed is not None :
1007
1026
self ._on_message_processed (topic_name , partition , offset )
1008
1027
1028
+ def _process_heartbeat (self , heartbeats_composed ):
1029
+ if not self ._heartbeat_active :
1030
+ return
1031
+
1032
+ now = datetime .now ().timestamp ()
1033
+ if self ._heartbeat_last_sent > now - self ._heartbeat_interval :
1034
+ return
1035
+
1036
+ value , key , timestamp , headers = None , None , int (now * 1000 ), {}
1037
+
1038
+ for tp in self ._consumer .assignment ():
1039
+ if executor := heartbeats_composed .get (tp .topic ):
1040
+ row = Row (
1041
+ value = value ,
1042
+ key = key ,
1043
+ timestamp = timestamp ,
1044
+ context = MessageContext (
1045
+ topic = tp .topic ,
1046
+ partition = tp .partition ,
1047
+ offset = - 1 , # TODO: get correct offsets
1048
+ size = - 1 ,
1049
+ ),
1050
+ headers = headers ,
1051
+ )
1052
+ context = copy_context ()
1053
+ context .run (set_message_context , row .context )
1054
+ try :
1055
+ context .run (executor , value , key , timestamp , headers )
1056
+ except Exception as exc :
1057
+ to_suppress = self ._on_processing_error (exc , row , logger )
1058
+ if not to_suppress :
1059
+ raise
1060
+
1061
+ self ._heartbeat_last_sent = now
1062
+
1009
1063
def _on_assign (self , _ , topic_partitions : List [TopicPartition ]):
1010
1064
"""
1011
1065
Assign new topic partitions to consumer and state.
0 commit comments