Source file kafka_consumer.ml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
type 'e handler_result =
| Continue
| Stop
| Error of 'e
type offset_reset =
| Earliest
| Latest
type config = {
brokers : string list;
group_id : string;
topics : string list;
offset_reset : offset_reset;
auto_commit : bool;
security : Kafka_security.t;
properties : (string * string) list;
}
type message = {
topic : string;
partition : int32;
offset : int64;
key : bytes option;
value : bytes option; (** [None] is a Kafka tombstone, distinct from [Some Bytes.empty] *)
timestamp : int64 option;
headers : (string * string option) list; (** [None] value is distinct from [Some ""] *)
}
external int_of_fd : Unix.file_descr -> int = "%identity"
external fd_of_int : int -> Unix.file_descr = "%identity"
type t = {
handle : Kafka_raw.kafka_handle;
config : config;
stream : message Eio.Stream.t;
closed : bool Atomic.t;
wake_source : Eio_unix.source_ty Eio.Std.r;
wake_sink : Eio_unix.sink_ty Eio.Std.r;
wake_fd : int Atomic.t;
poll_exited : unit Eio.Promise.t;
poll_exit_r : unit Eio.Promise.u;
last_processed : (string * int32, int64) Hashtbl.t;
}
let err i = Result.error (Kafka_error.of_int i)
let default_on_warning msg = Printf.eprintf "kafka-eio: %s\n%!" msg
let conf_of_config (cfg : config) : (Kafka_raw.kafka_conf, string) result =
let ( let* ) = Result.bind in
let conf = Kafka_raw.conf_new () in
let set k v =
Kafka_raw.conf_set conf k v
|> Result.map_error (fun s -> "kafka conf " ^ k ^ ": " ^ s)
in
let* () = set "bootstrap.servers" (String.concat "," cfg.brokers) in
let* () = set "group.id" cfg.group_id in
let* () = set "auto.offset.reset"
(match cfg.offset_reset with Earliest -> "earliest" | Latest -> "latest")
in
let* () = set "enable.auto.commit" (if cfg.auto_commit then "true" else "false") in
let* () = set "partition.assignment.strategy" "range,roundrobin" in
let* () = Kafka_security.apply conf cfg.security in
let* () =
List.fold_left (fun acc (k, v) -> let* () = acc in set k v) (Ok ()) cfg.properties
in
Ok conf
let tuple_to_message (topic, partition, offset, key, value, timestamp, ) =
{ topic; partition; offset; key; value; timestamp; headers }
let poll_fiber t sw ~on_ready ~on_poll_error =
let wake_source = t.wake_source and wake_sink = t.wake_sink in
let write_fd_int =
Eio_unix.Fd.use_exn "kafka_consumer_queue_wake_fd"
(Eio_unix.Resource.fd wake_sink) int_of_fd
in
Atomic.set t.wake_fd write_fd_int;
Kafka_raw.consumer_queue_events_enable t.handle write_fd_int;
Eio.Fiber.fork_daemon ~sw (fun () ->
let wake_buf = Cstruct.create 4096 in
let notified = ref false in
let prev_assignment = ref None in
let rec drain () =
let assignment = Kafka_raw.assignment t.handle |> List.sort compare in
if Some assignment <> !prev_assignment then begin
if Option.is_some !prev_assignment then Hashtbl.reset t.last_processed;
prev_assignment := Some assignment
end;
if not !notified && assignment <> [] then begin
notified := true; on_ready ()
end;
match Kafka_raw.consumer_queue_poll t.handle 0 with
| Kafka_raw.Timeout -> ()
| Kafka_raw.Msg tup ->
if not !notified then begin notified := true; on_ready () end;
Eio.Stream.add t.stream (tuple_to_message tup);
drain ()
| Kafka_raw.Poll_error code ->
on_poll_error code;
Eio.Fiber.yield ();
drain ()
in
let rec loop () =
if Atomic.get t.closed then ()
else
match Eio.Flow.single_read wake_source wake_buf with
| exception (Eio.Cancel.Cancelled _) -> ()
| exception End_of_file -> ()
| _n -> drain (); loop ()
in
Fun.protect
~finally:(fun () -> Eio.Promise.resolve t.poll_exit_r ())
(fun () ->
try drain (); loop (); `Stop_daemon
with Eio.Cancel.Cancelled _ -> `Stop_daemon))
let close t =
if Atomic.compare_and_set t.closed false true then
Eio.Cancel.protect (fun () ->
Kafka_raw.consumer_queue_events_disable t.handle;
let wfd = Atomic.get t.wake_fd in
if wfd >= 0 then begin
let buf = Bytes.make 1 '\x01' in
(try ignore (Unix.write (fd_of_int wfd) buf 0 1)
with Unix.Unix_error (Unix.EPIPE, _, _) -> ()
| Unix.Unix_error _ -> ())
end;
let rec drain_until_exited () =
while not (Eio.Stream.is_empty t.stream) do
ignore (Eio.Stream.take_nonblocking t.stream)
done;
if Eio.Promise.peek t.poll_exited = None then begin
Eio.Fiber.yield ();
drain_until_exited ()
end
in
drain_until_exited ();
Kafka_raw.consumer_close t.handle;
Kafka_raw.destroy t.handle;
Eio.Flow.close t.wake_source;
Eio.Flow.close t.wake_sink)
let default_on_poll_error code =
Printf.eprintf "kafka-eio: consumer poll error: %s\n%!"
(Kafka_error.to_string (Kafka_error.of_int code))
let create ?(on_ready = ignore) ?(on_poll_error = default_on_poll_error) (cfg : config) ~sw =
match conf_of_config cfg with
| Error msg -> Result.error (Kafka_error.Config_error msg)
| Ok conf ->
match Kafka_raw.kafka_new Kafka_raw.Consumer conf (-1) with
| Error msg -> Result.error (Kafka_error.Config_error msg)
| Ok rk_handle ->
(match Kafka_raw.subscribe rk_handle cfg.topics with
| Error msg ->
Kafka_raw.destroy rk_handle;
Result.error (Kafka_error.Config_error msg)
| Ok () ->
let (poll_exited, poll_exit_r) = Eio.Promise.create () in
let (wake_source, wake_sink) = Eio_unix.pipe sw in
let t = {
handle = rk_handle;
config = cfg;
stream = Eio.Stream.create 256;
closed = Atomic.make false;
wake_source;
wake_sink;
wake_fd = Atomic.make (-1);
poll_exited;
poll_exit_r;
last_processed = Hashtbl.create 4;
} in
poll_fiber t sw ~on_ready ~on_poll_error;
Eio.Switch.on_release sw (fun () -> close t);
Result.ok t)
let is_closed t = Atomic.get t.closed
let handle t = Kafka_consumer_handle.of_raw t.handle
let commit_tracked t ~topic ~partition ~offset =
match Kafka_raw.commit_message t.handle ~topic ~partition ~offset ~async:false with
| Ok () ->
Hashtbl.replace t.last_processed (topic, partition) offset;
Result.ok ()
| Error i -> err i
let stream t = t.stream
let fetch t =
if is_closed t then Result.error Kafka_error.Destroy
else Result.ok (Eio.Stream.take t.stream)
let consume t ?(on_warning = default_on_warning) ~handler () =
let rec loop () =
let msg = Eio.Stream.take t.stream in
let acked = ref false in
let ack () =
acked := true;
if is_closed t then Result.error Kafka_error.Destroy
else commit_tracked t ~topic:msg.topic ~partition:msg.partition ~offset:msg.offset
in
let result = handler msg ~ack in
(match result with
| (Continue | Stop) when not !acked ->
on_warning
(Printf.sprintf
"handler returned without calling ack() — offset not committed \
(topic=%s partition=%ld offset=%Ld)"
msg.topic msg.partition msg.offset)
| _ -> ());
match result with
| Continue -> loop ()
| Stop -> Result.ok ()
| Error e -> Result.error e
in
loop ()
let poll t =
if is_closed t then Result.error Kafka_error.Destroy
else Result.ok (Eio.Stream.take_nonblocking t.stream)
let commit t msg =
if is_closed t then Result.error Kafka_error.Destroy
else commit_tracked t ~topic:msg.topic ~partition:msg.partition ~offset:msg.offset
let commit_all t =
if is_closed t then Result.error Kafka_error.Destroy
else if t.config.auto_commit then
(match Kafka_raw.commit_all t.handle false with
| Ok () -> Result.ok ()
| Error i -> err i)
else
let offsets =
Hashtbl.fold (fun (topic, partition) offset acc -> (topic, partition, offset) :: acc)
t.last_processed []
in
if offsets = [] then Result.ok ()
else
match Kafka_raw.commit_offsets t.handle offsets false with
| Ok () -> Result.ok ()
| Error i -> err i
type retry_policy = {
base_delay_s : float;
max_delay_s : float;
max_attempts : int;
}
let default_retry = {
base_delay_s = 1.0;
max_delay_s = 600.0;
max_attempts = -1;
}
let default_queue_capacity = 16
let consume_partitioned t ~sw:_ ~clock ?(retry = default_retry)
?(on_retry = fun ~partition:_ ~attempt:_ ~delay_s:_ -> ())
?(on_warning = default_on_warning)
?(queue_capacity = default_queue_capacity)
~handler () =
let stop = Atomic.make false in
let stop_p, stop_r = Eio.Promise.create () in
let first_err = ref None in
let streams
: (int32, (message * (unit -> (unit, Kafka_error.t) result)) option Eio.Stream.t) Hashtbl.t =
Hashtbl.create 4
in
let signal_stop () =
if Atomic.compare_and_set stop false true then
Eio.Promise.resolve stop_r ()
in
Eio.Switch.run (fun sw ->
Eio.Fiber.fork_daemon ~sw (fun () ->
let rec watch () =
if Atomic.get stop then `Stop_daemon
else if is_closed t then (signal_stop (); `Stop_daemon)
else (Eio.Time.sleep clock 0.1; watch ())
in
watch ());
let get_or_create_stream partition =
match Hashtbl.find_opt streams partition with
| Some s -> s
| None ->
let stream = Eio.Stream.create queue_capacity in
Hashtbl.add streams partition stream;
Eio.Fiber.fork ~sw (fun () ->
let rec loop () =
match Eio.Stream.take stream with
| None -> ()
| Some (msg, ack) ->
if Atomic.get stop || is_closed t then loop ()
else begin
let acked = ref false in
let tracked_ack () = acked := true; ack () in
let rec attempt n =
match handler msg ~ack:tracked_ack with
| Continue ->
if not !acked then
on_warning
(Printf.sprintf
"handler returned Continue without ack() \
(topic=%s partition=%ld offset=%Ld)"
msg.topic msg.partition msg.offset);
loop ()
| Stop ->
if not !acked then
on_warning
(Printf.sprintf
"handler returned Stop without ack() \
(topic=%s partition=%ld offset=%Ld)"
msg.topic msg.partition msg.offset);
signal_stop ();
loop ()
| Error e ->
let exhausted =
retry.max_attempts >= 0 && n >= retry.max_attempts
in
if exhausted then begin
on_warning
(Printf.sprintf
"exhausted %d attempt(s) for topic=%s partition=%ld offset=%Ld"
(n + 1) msg.topic msg.partition msg.offset);
first_err := Some e;
signal_stop ();
loop ()
end else begin
let delay =
Float.min
(retry.base_delay_s *. (2. ** Float.of_int n))
retry.max_delay_s
in
on_warning
(Printf.sprintf
"attempt %d failed, retrying in %.0fs (topic=%s partition=%ld offset=%Ld)"
(n + 1) delay msg.topic msg.partition msg.offset);
on_retry ~partition:msg.partition ~attempt:n ~delay_s:delay;
if not (is_closed t) then
Kafka_raw.pause_partition t.handle msg.topic msg.partition;
let interrupted =
Eio.Fiber.first
(fun () -> Eio.Time.sleep clock delay; false)
(fun () -> Eio.Promise.await stop_p; true)
in
if not (is_closed t) then
Kafka_raw.resume_partition t.handle msg.topic msg.partition;
if not interrupted then attempt (n + 1)
else loop ()
end
in
attempt 0
end
in
loop ()
);
stream
in
let rec routing_loop () =
if Atomic.get stop || is_closed t then ()
else begin
let msg_opt =
match Eio.Stream.take_nonblocking t.stream with
| Some _ as m -> m
| None ->
Eio.Fiber.first
(fun () -> Some (Eio.Stream.take t.stream))
(fun () -> Eio.Promise.await stop_p; None)
in
match msg_opt with
| None -> ()
| Some msg ->
let ack () =
if is_closed t then Result.error Kafka_error.Destroy
else commit_tracked t ~topic:msg.topic ~partition:msg.partition ~offset:msg.offset
in
Eio.Stream.add (get_or_create_stream msg.partition) (Some (msg, ack));
routing_loop ()
end
in
routing_loop ();
Hashtbl.iter (fun _ s -> Eio.Stream.add s None) streams
);
match !first_err with
| Some e -> Result.error e
| None -> Result.ok ()