ringo-task-queue 0.1.0.dev3__py3-none-win_amd64.whl → 0.1.0.dev5__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ringo_task_queue/__init__.py +71 -68
- ringo_task_queue/_pb.py +154 -154
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +104 -100
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +50 -5
- ringo_task_queue/_version.py +17 -15
- ringo_task_queue/bin/manifest.json +7 -7
- ringo_task_queue/bin/ringo-task-queue-windows-amd64.exe +0 -0
- ringo_task_queue/binary.py +253 -253
- ringo_task_queue/client.py +1071 -897
- ringo_task_queue/concurrency.py +480 -0
- ringo_task_queue/daemon.py +507 -507
- ringo_task_queue/errors.py +159 -152
- ringo_task_queue/models.py +364 -364
- ringo_task_queue/shared.py +305 -0
- ringo_task_queue/worker.py +1297 -994
- {ringo_task_queue-0.1.0.dev3.dist-info → ringo_task_queue-0.1.0.dev5.dist-info}/METADATA +66 -34
- ringo_task_queue-0.1.0.dev5.dist-info/RECORD +20 -0
- {ringo_task_queue-0.1.0.dev3.dist-info → ringo_task_queue-0.1.0.dev5.dist-info}/WHEEL +1 -1
- {ringo_task_queue-0.1.0.dev3.dist-info → ringo_task_queue-0.1.0.dev5.dist-info}/licenses/LICENSE +21 -21
- ringo_task_queue-0.1.0.dev3.dist-info/RECORD +0 -18
ringo_task_queue/client.py
CHANGED
|
@@ -1,897 +1,1071 @@
|
|
|
1
|
-
"""Connection management and the user-facing client/queue APIs."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import os
|
|
7
|
-
import secrets
|
|
8
|
-
from datetime import timedelta
|
|
9
|
-
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Iterable, Literal
|
|
10
|
-
|
|
11
|
-
import grpc
|
|
12
|
-
from grpc_health.v1 import health_pb2, health_pb2_grpc
|
|
13
|
-
|
|
14
|
-
from . import _pb
|
|
15
|
-
from ._proto.ringo.v1 import queue_pb2, queue_pb2_grpc
|
|
16
|
-
from ._version import PROTOCOL_MAJOR, PROTOCOL_MINOR, SDK_VERSION
|
|
17
|
-
from .
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
from .
|
|
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
|
-
if
|
|
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
|
-
self.
|
|
148
|
-
self.
|
|
149
|
-
self.
|
|
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
|
-
if
|
|
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
|
-
try:
|
|
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
|
-
if
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
self.
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
"
|
|
714
|
-
)
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
)
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
)
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
await self.
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1
|
+
"""Connection management and the user-facing client/queue APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Iterable, Literal
|
|
10
|
+
|
|
11
|
+
import grpc
|
|
12
|
+
from grpc_health.v1 import health_pb2, health_pb2_grpc
|
|
13
|
+
|
|
14
|
+
from . import _pb
|
|
15
|
+
from ._proto.ringo.v1 import queue_pb2, queue_pb2_grpc
|
|
16
|
+
from ._version import PROTOCOL_MAJOR, PROTOCOL_MINOR, SDK_VERSION
|
|
17
|
+
from .concurrency import AdaptiveConcurrency, ResourceConcurrency
|
|
18
|
+
from .errors import (
|
|
19
|
+
IncompatibleVersionError,
|
|
20
|
+
InvalidArgumentError,
|
|
21
|
+
RingoError,
|
|
22
|
+
UnavailableError,
|
|
23
|
+
map_rpc_error,
|
|
24
|
+
)
|
|
25
|
+
from .models import (
|
|
26
|
+
BatchResult,
|
|
27
|
+
EnqueueResult,
|
|
28
|
+
PostgresStorage,
|
|
29
|
+
SQLiteStorage,
|
|
30
|
+
Task,
|
|
31
|
+
TaskSpec,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
35
|
+
from .daemon import LocalDaemon
|
|
36
|
+
from .shared import SharedLocalDaemon
|
|
37
|
+
from .worker import Worker
|
|
38
|
+
|
|
39
|
+
SDK_NAME = "ringo-python"
|
|
40
|
+
|
|
41
|
+
DEFAULT_RPC_TIMEOUT = 30.0
|
|
42
|
+
_MAX_ENQUEUE_BATCH = 1000
|
|
43
|
+
_RECONNECT_ATTEMPTS = 3
|
|
44
|
+
_BACKOFF_SECONDS = (0.2, 0.6, 1.5)
|
|
45
|
+
|
|
46
|
+
#: Supported values for the remote ``compression`` option: ``"none"``
|
|
47
|
+
#: (the default) or ``"gzip"``. ``None`` means "not specified": the channel
|
|
48
|
+
#: default applies and a legacy raw ``channel_options`` compression entry
|
|
49
|
+
#: keeps its meaning. The runtime validation in
|
|
50
|
+
#: :func:`_normalize_compression` still rejects any other value for dynamic
|
|
51
|
+
#: callers.
|
|
52
|
+
CompressionOption = Literal["none", "gzip"]
|
|
53
|
+
_COMPRESSION_CHOICES: tuple[str, ...] = ("none", "gzip")
|
|
54
|
+
#: gRPC channel option that controls the same thing as ``compression``. An
|
|
55
|
+
#: explicit ``compression`` (even ``"none"``) combined with this raw option is
|
|
56
|
+
#: an ambiguity error, mirroring the Node SDK.
|
|
57
|
+
_COMPRESSION_CHANNEL_OPTION = "grpc.default_compression_algorithm"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_compression(value: CompressionOption | None) -> grpc.Compression | None:
|
|
61
|
+
"""Map the public ``compression`` option onto ``grpc.Compression``.
|
|
62
|
+
|
|
63
|
+
``None`` (unspecified) maps to ``None`` so the channel default applies.
|
|
64
|
+
Anything else outside ``_COMPRESSION_CHOICES`` is a clear
|
|
65
|
+
``InvalidArgumentError`` raised before any network activity.
|
|
66
|
+
"""
|
|
67
|
+
if value is None:
|
|
68
|
+
return None
|
|
69
|
+
if not isinstance(value, str) or value not in _COMPRESSION_CHOICES:
|
|
70
|
+
raise InvalidArgumentError(
|
|
71
|
+
f"unsupported compression {value!r}; expected one of"
|
|
72
|
+
f" {', '.join(_COMPRESSION_CHOICES)}"
|
|
73
|
+
)
|
|
74
|
+
if value == "gzip":
|
|
75
|
+
return grpc.Compression.Gzip
|
|
76
|
+
return grpc.Compression.NoCompression
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _check_compression_channel_option_conflict(
|
|
80
|
+
channel_options: Iterable[tuple[str, Any]] | None,
|
|
81
|
+
compression: grpc.Compression | None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Reject an explicit ``compression`` alongside the raw gRPC option.
|
|
84
|
+
|
|
85
|
+
When ``compression`` is unspecified (``None``), raw ``channel_options``
|
|
86
|
+
keep their legacy meaning for advanced users.
|
|
87
|
+
"""
|
|
88
|
+
if compression is None:
|
|
89
|
+
return
|
|
90
|
+
for key, _ in channel_options or ():
|
|
91
|
+
if key == _COMPRESSION_CHANNEL_OPTION:
|
|
92
|
+
raise InvalidArgumentError(
|
|
93
|
+
f"compression and channel_options[{_COMPRESSION_CHANNEL_OPTION!r}]"
|
|
94
|
+
" must not both be set"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _consume_task_exception(task: asyncio.Task[Any]) -> None:
|
|
99
|
+
if not task.cancelled():
|
|
100
|
+
task.exception()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _parse_endpoint(endpoint: str) -> tuple[str, bool]:
|
|
104
|
+
"""Return ``(authority, use_tls)`` for an endpoint string."""
|
|
105
|
+
if not endpoint:
|
|
106
|
+
raise InvalidArgumentError("endpoint is required")
|
|
107
|
+
use_tls = False
|
|
108
|
+
rest = endpoint
|
|
109
|
+
for scheme, tls in (
|
|
110
|
+
("grpc://", False),
|
|
111
|
+
("http://", False),
|
|
112
|
+
("grpcs://", True),
|
|
113
|
+
("https://", True),
|
|
114
|
+
):
|
|
115
|
+
if rest.startswith(scheme):
|
|
116
|
+
rest = rest[len(scheme) :]
|
|
117
|
+
use_tls = tls
|
|
118
|
+
break
|
|
119
|
+
rest = rest.rstrip("/")
|
|
120
|
+
if ":" not in rest:
|
|
121
|
+
rest = f"{rest}:7233"
|
|
122
|
+
return rest, use_tls
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
#: Bounded automatic restarts of a crashed local daemon (design §31).
|
|
126
|
+
_MAX_DAEMON_RESTARTS = 3
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _Connection:
|
|
130
|
+
"""Owns the grpc.aio channel, negotiation, and retried unary calls."""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
endpoint: str,
|
|
135
|
+
token: str | None = None,
|
|
136
|
+
*,
|
|
137
|
+
local_token: str | None = None,
|
|
138
|
+
local_generation: str | None = None,
|
|
139
|
+
channel_options: Iterable[tuple[str, Any]] | None = None,
|
|
140
|
+
compression: grpc.Compression | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
self._endpoint, self._tls = _parse_endpoint(endpoint)
|
|
143
|
+
self._token = token
|
|
144
|
+
self._local_token = local_token
|
|
145
|
+
self._local_generation = local_generation
|
|
146
|
+
self._attach_call: Any = None
|
|
147
|
+
self._attach_reader_task: asyncio.Task[Any] | None = None
|
|
148
|
+
self._shared_disconnect: Callable[[], Awaitable[None]] | None = None
|
|
149
|
+
self._channel_options = list(channel_options or ())
|
|
150
|
+
if local_token is not None:
|
|
151
|
+
configured = {key for key, _ in self._channel_options}
|
|
152
|
+
for key, value in (
|
|
153
|
+
("grpc.keepalive_time_ms", 15000),
|
|
154
|
+
("grpc.keepalive_timeout_ms", 10000),
|
|
155
|
+
("grpc.keepalive_permit_without_calls", 1),
|
|
156
|
+
):
|
|
157
|
+
if key not in configured:
|
|
158
|
+
self._channel_options.append((key, value))
|
|
159
|
+
_check_compression_channel_option_conflict(
|
|
160
|
+
self._channel_options, compression
|
|
161
|
+
)
|
|
162
|
+
# Channel-level default compression; survives every reconnect because
|
|
163
|
+
# _open_channel() re-applies it to each new aio channel. ``None`` means
|
|
164
|
+
# unspecified, so legacy raw channel options decide the encoding.
|
|
165
|
+
self._compression = compression
|
|
166
|
+
self._channel: grpc.aio.Channel | None = None
|
|
167
|
+
self._stub: queue_pb2_grpc.QueueServiceStub | None = None
|
|
168
|
+
self._session_minor = PROTOCOL_MINOR
|
|
169
|
+
self._session_token = ""
|
|
170
|
+
self._lock = asyncio.Lock()
|
|
171
|
+
self._closed = False
|
|
172
|
+
#: Optional client-provided reconnect hook (daemon restart + channel
|
|
173
|
+
#: rebuild). Invoked under ``_lock`` instead of plain ``negotiate``.
|
|
174
|
+
self.on_reconnect: Callable[[], Awaitable[None]] | None = None
|
|
175
|
+
|
|
176
|
+
# -- lifecycle ---------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _open_channel(self) -> None:
|
|
179
|
+
if self._tls:
|
|
180
|
+
credentials = grpc.ssl_channel_credentials()
|
|
181
|
+
self._channel = grpc.aio.secure_channel(
|
|
182
|
+
self._endpoint,
|
|
183
|
+
credentials,
|
|
184
|
+
options=self._channel_options,
|
|
185
|
+
compression=self._compression,
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
self._channel = grpc.aio.insecure_channel(
|
|
189
|
+
self._endpoint,
|
|
190
|
+
options=self._channel_options,
|
|
191
|
+
compression=self._compression,
|
|
192
|
+
)
|
|
193
|
+
self._stub = queue_pb2_grpc.QueueServiceStub(self._channel)
|
|
194
|
+
|
|
195
|
+
async def connect(self, *, check_health: bool = False) -> None:
|
|
196
|
+
self._open_channel()
|
|
197
|
+
if check_health:
|
|
198
|
+
await self._await_serving()
|
|
199
|
+
if self._local_token is not None:
|
|
200
|
+
await self.attach_local()
|
|
201
|
+
await self.negotiate()
|
|
202
|
+
|
|
203
|
+
async def attach_local(self) -> None:
|
|
204
|
+
"""Register this SDK process as a shared-daemon member."""
|
|
205
|
+
if self._local_token is None or self._local_generation is None:
|
|
206
|
+
return
|
|
207
|
+
stub = self._require_stub()
|
|
208
|
+
try:
|
|
209
|
+
request = queue_pb2.AttachLocalRequest(generation=self._local_generation)
|
|
210
|
+
call = stub.AttachLocal(request, metadata=self._metadata())
|
|
211
|
+
response = await call.read()
|
|
212
|
+
if response is grpc.aio.EOF or response is None:
|
|
213
|
+
raise UnavailableError("shared daemon closed AttachLocal during handshake")
|
|
214
|
+
if response.generation != self._local_generation:
|
|
215
|
+
call.cancel()
|
|
216
|
+
raise UnavailableError("shared daemon generation changed during AttachLocal")
|
|
217
|
+
except grpc.aio.AioRpcError as exc:
|
|
218
|
+
raise map_rpc_error(exc) from exc
|
|
219
|
+
self._attach_call = call
|
|
220
|
+
self._attach_reader_task = asyncio.create_task(
|
|
221
|
+
self._consume_attach(call), name="ringo-shared-attach"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
async def _consume_attach(self, call: Any) -> None:
|
|
225
|
+
try:
|
|
226
|
+
while True:
|
|
227
|
+
response = await call.read()
|
|
228
|
+
if response is grpc.aio.EOF:
|
|
229
|
+
break
|
|
230
|
+
if response is None:
|
|
231
|
+
break
|
|
232
|
+
if response.generation != self._local_generation:
|
|
233
|
+
break
|
|
234
|
+
except asyncio.CancelledError:
|
|
235
|
+
return
|
|
236
|
+
except grpc.aio.AioRpcError:
|
|
237
|
+
pass
|
|
238
|
+
finally:
|
|
239
|
+
if call is self._attach_call:
|
|
240
|
+
self._attach_call = None
|
|
241
|
+
if not self._closed and self._shared_disconnect is not None:
|
|
242
|
+
asyncio.ensure_future(self._shared_disconnect())
|
|
243
|
+
|
|
244
|
+
def set_shared_session(
|
|
245
|
+
self, token: str, generation: str, on_disconnect: Callable[[], Awaitable[None]]
|
|
246
|
+
) -> None:
|
|
247
|
+
self._local_token = token
|
|
248
|
+
self._local_generation = generation
|
|
249
|
+
self._shared_disconnect = on_disconnect
|
|
250
|
+
|
|
251
|
+
async def _close_attach(self) -> None:
|
|
252
|
+
task, self._attach_reader_task = self._attach_reader_task, None
|
|
253
|
+
call, self._attach_call = self._attach_call, None
|
|
254
|
+
if call is not None:
|
|
255
|
+
try:
|
|
256
|
+
call.cancel()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
if task is not None and task is not asyncio.current_task():
|
|
260
|
+
task.cancel()
|
|
261
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
262
|
+
|
|
263
|
+
async def _await_serving(self, timeout: float = 5.0) -> None:
|
|
264
|
+
"""Wait until the health service reports SERVING (local daemon
|
|
265
|
+
readiness is announced on stdout before the gRPC health state may be
|
|
266
|
+
observable, so poll briefly instead of assuming)."""
|
|
267
|
+
assert self._channel is not None
|
|
268
|
+
health_stub = health_pb2_grpc.HealthStub(self._channel)
|
|
269
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
270
|
+
last: Exception | None = None
|
|
271
|
+
while True:
|
|
272
|
+
try:
|
|
273
|
+
response = await health_stub.Check(
|
|
274
|
+
health_pb2.HealthCheckRequest(),
|
|
275
|
+
timeout=2.0,
|
|
276
|
+
metadata=self._metadata(),
|
|
277
|
+
)
|
|
278
|
+
if response.status == health_pb2.HealthCheckResponse.SERVING:
|
|
279
|
+
return
|
|
280
|
+
last = RingoError("daemon health status is not SERVING")
|
|
281
|
+
except grpc.aio.AioRpcError as exc:
|
|
282
|
+
last = exc
|
|
283
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
284
|
+
raise RingoError(f"daemon did not report SERVING health: {last}")
|
|
285
|
+
await asyncio.sleep(0.05)
|
|
286
|
+
|
|
287
|
+
async def reset(self, endpoint: str) -> None:
|
|
288
|
+
"""Point the connection at a new endpoint (local daemon restart)."""
|
|
289
|
+
await self._close_attach()
|
|
290
|
+
self._endpoint, self._tls = _parse_endpoint(endpoint)
|
|
291
|
+
channel, self._channel = self._channel, None
|
|
292
|
+
self._stub = None
|
|
293
|
+
if channel is not None:
|
|
294
|
+
await channel.close()
|
|
295
|
+
self._open_channel()
|
|
296
|
+
|
|
297
|
+
async def negotiate(self) -> None:
|
|
298
|
+
"""Perform the major=1/minor=0 version handshake (ADR 0002)."""
|
|
299
|
+
stub = self._require_stub()
|
|
300
|
+
request = queue_pb2.NegotiateRequest(
|
|
301
|
+
sdk_name=SDK_NAME,
|
|
302
|
+
sdk_version=SDK_VERSION,
|
|
303
|
+
protocol_major=PROTOCOL_MAJOR,
|
|
304
|
+
protocol_minor=PROTOCOL_MINOR,
|
|
305
|
+
)
|
|
306
|
+
try:
|
|
307
|
+
response = await stub.Negotiate(
|
|
308
|
+
request, timeout=DEFAULT_RPC_TIMEOUT, metadata=self._metadata()
|
|
309
|
+
)
|
|
310
|
+
except grpc.aio.AioRpcError as exc:
|
|
311
|
+
raise map_rpc_error(exc) from exc
|
|
312
|
+
if response.protocol_major != PROTOCOL_MAJOR:
|
|
313
|
+
raise IncompatibleVersionError(
|
|
314
|
+
f"server speaks protocol major {response.protocol_major},"
|
|
315
|
+
f" SDK requires major {PROTOCOL_MAJOR}; upgrade one side"
|
|
316
|
+
)
|
|
317
|
+
self._session_minor = min(response.protocol_minor, PROTOCOL_MINOR)
|
|
318
|
+
self._session_token = response.session_token
|
|
319
|
+
|
|
320
|
+
async def close(self) -> None:
|
|
321
|
+
self._closed = True
|
|
322
|
+
await self._close_attach()
|
|
323
|
+
channel, self._channel = self._channel, None
|
|
324
|
+
self._stub = None
|
|
325
|
+
if channel is not None:
|
|
326
|
+
await channel.close()
|
|
327
|
+
|
|
328
|
+
# -- helpers -----------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def _require_stub(self) -> queue_pb2_grpc.QueueServiceStub:
|
|
331
|
+
if self._stub is None:
|
|
332
|
+
raise RingoError("connection is closed")
|
|
333
|
+
return self._stub
|
|
334
|
+
|
|
335
|
+
def _metadata(self) -> list[tuple[str, str]]:
|
|
336
|
+
metadata: list[tuple[str, str]] = []
|
|
337
|
+
if self._token:
|
|
338
|
+
metadata.append(("authorization", f"Bearer {self._token}"))
|
|
339
|
+
if self._local_token:
|
|
340
|
+
metadata.append(("x-ringo-local-token", self._local_token))
|
|
341
|
+
return metadata
|
|
342
|
+
|
|
343
|
+
def request_context(self) -> queue_pb2.RequestContext:
|
|
344
|
+
return queue_pb2.RequestContext(
|
|
345
|
+
protocol_major=PROTOCOL_MAJOR,
|
|
346
|
+
protocol_minor=self._session_minor,
|
|
347
|
+
session_token=self._session_token,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def endpoint(self) -> str:
|
|
352
|
+
return self._endpoint
|
|
353
|
+
|
|
354
|
+
async def call(
|
|
355
|
+
self,
|
|
356
|
+
name: str,
|
|
357
|
+
request: Any,
|
|
358
|
+
*,
|
|
359
|
+
timeout: float = DEFAULT_RPC_TIMEOUT,
|
|
360
|
+
retry_safe: bool = False,
|
|
361
|
+
context_attr: str | None = "context",
|
|
362
|
+
) -> Any:
|
|
363
|
+
"""Issue a single unary RPC.
|
|
364
|
+
|
|
365
|
+
Submission is never silently replayed: an ``UNAVAILABLE`` may mean the
|
|
366
|
+
server committed the operation (a lost ACK response, a claimed lease)
|
|
367
|
+
so the original error is raised after best-effort recovery (daemon
|
|
368
|
+
restart + fresh negotiation) prepares the connection for *future*
|
|
369
|
+
calls. Only genuinely read-only RPCs (``GetTask``) pass
|
|
370
|
+
``retry_safe=True`` and may be re-sent, each time with a freshly
|
|
371
|
+
negotiated ``RequestContext``.
|
|
372
|
+
"""
|
|
373
|
+
rpc, sent_token = await self._begin_unary(
|
|
374
|
+
name, request, timeout=timeout, context_attr=context_attr
|
|
375
|
+
)
|
|
376
|
+
cause: BaseException | None = None
|
|
377
|
+
err: RingoError | None = None
|
|
378
|
+
try:
|
|
379
|
+
return await rpc
|
|
380
|
+
except grpc.aio.AioRpcError as exc:
|
|
381
|
+
cause = exc
|
|
382
|
+
err = map_rpc_error(exc)
|
|
383
|
+
except asyncio.CancelledError as exc:
|
|
384
|
+
# grpc.aio raises CancelledError when a reconnect closes its old
|
|
385
|
+
# channel. Preserve genuine caller cancellation, but surface this
|
|
386
|
+
# transport race as UNAVAILABLE.
|
|
387
|
+
task = asyncio.current_task()
|
|
388
|
+
if task is not None and task.cancelling():
|
|
389
|
+
raise
|
|
390
|
+
cause = exc
|
|
391
|
+
err = UnavailableError("RPC interrupted by connection recovery")
|
|
392
|
+
|
|
393
|
+
assert err is not None and cause is not None
|
|
394
|
+
if self._closed:
|
|
395
|
+
raise err from cause
|
|
396
|
+
if isinstance(err, IncompatibleVersionError):
|
|
397
|
+
# Session rejection happens in authorization before the operation
|
|
398
|
+
# can execute. It is therefore safe to negotiate a fresh short-lived
|
|
399
|
+
# token and retry once, including ordinary token expiry with no
|
|
400
|
+
# concurrent daemon restart.
|
|
401
|
+
await self._recover_best_effort()
|
|
402
|
+
async with self._lock:
|
|
403
|
+
rotated = self._session_token != sent_token
|
|
404
|
+
if rotated:
|
|
405
|
+
rpc, _ = await self._begin_unary(
|
|
406
|
+
name, request, timeout=timeout, context_attr=context_attr
|
|
407
|
+
)
|
|
408
|
+
try:
|
|
409
|
+
return await rpc
|
|
410
|
+
except grpc.aio.AioRpcError as retry_exc:
|
|
411
|
+
raise map_rpc_error(retry_exc) from retry_exc
|
|
412
|
+
raise err from cause
|
|
413
|
+
if not isinstance(err, UnavailableError):
|
|
414
|
+
raise err from cause
|
|
415
|
+
|
|
416
|
+
# Recovery prepares the connection for future calls. Mutations are not
|
|
417
|
+
# replayed because an unavailable response may hide a committed write.
|
|
418
|
+
await self._recover_best_effort()
|
|
419
|
+
if not retry_safe:
|
|
420
|
+
raise err from cause
|
|
421
|
+
rpc, _ = await self._begin_unary(
|
|
422
|
+
name, request, timeout=timeout, context_attr=context_attr
|
|
423
|
+
)
|
|
424
|
+
try:
|
|
425
|
+
return await rpc
|
|
426
|
+
except grpc.aio.AioRpcError as retry_exc:
|
|
427
|
+
raise map_rpc_error(retry_exc) from retry_exc
|
|
428
|
+
|
|
429
|
+
async def _begin_unary(
|
|
430
|
+
self,
|
|
431
|
+
name: str,
|
|
432
|
+
request: Any,
|
|
433
|
+
*,
|
|
434
|
+
timeout: float,
|
|
435
|
+
context_attr: str | None,
|
|
436
|
+
) -> tuple[Any, str]:
|
|
437
|
+
"""Snapshot a stub and fresh session context outside reconnect races."""
|
|
438
|
+
async with self._lock:
|
|
439
|
+
if context_attr is not None:
|
|
440
|
+
getattr(request, context_attr).CopyFrom(self.request_context())
|
|
441
|
+
stub = self._require_stub()
|
|
442
|
+
token = self._session_token
|
|
443
|
+
rpc = getattr(stub, name)(
|
|
444
|
+
request, timeout=timeout, metadata=self._metadata()
|
|
445
|
+
)
|
|
446
|
+
return rpc, token
|
|
447
|
+
|
|
448
|
+
async def _recover_best_effort(self) -> None:
|
|
449
|
+
"""Bounded reconnect so later calls can succeed; failures here are
|
|
450
|
+
swallowed because the original call error is what the caller sees."""
|
|
451
|
+
for attempt in range(_RECONNECT_ATTEMPTS):
|
|
452
|
+
try:
|
|
453
|
+
await asyncio.sleep(
|
|
454
|
+
_BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)]
|
|
455
|
+
)
|
|
456
|
+
async with self._lock:
|
|
457
|
+
if self._closed:
|
|
458
|
+
return
|
|
459
|
+
if self.on_reconnect is not None:
|
|
460
|
+
await self.on_reconnect()
|
|
461
|
+
else:
|
|
462
|
+
await self.negotiate()
|
|
463
|
+
return
|
|
464
|
+
except Exception:
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def new_worker_id() -> str:
|
|
469
|
+
return f"py-{os.getpid()}-{secrets.token_hex(4)}"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
# Public client
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class RingoClient:
|
|
477
|
+
"""A connected (or connectable) Ringo client.
|
|
478
|
+
|
|
479
|
+
Use as an async context manager, or call :meth:`start` / :meth:`close`
|
|
480
|
+
explicitly::
|
|
481
|
+
|
|
482
|
+
async with Ringo.connect("127.0.0.1:7233") as client:
|
|
483
|
+
queue = client.queue("crawl")
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
def __init__(
|
|
487
|
+
self,
|
|
488
|
+
*,
|
|
489
|
+
endpoint: str | None = None,
|
|
490
|
+
token: str | None = None,
|
|
491
|
+
daemon: "LocalDaemon | None" = None,
|
|
492
|
+
shared_daemon: "SharedLocalDaemon | None" = None,
|
|
493
|
+
channel_options: Iterable[tuple[str, Any]] | None = None,
|
|
494
|
+
compression: CompressionOption | None = None,
|
|
495
|
+
) -> None:
|
|
496
|
+
self._endpoint = endpoint
|
|
497
|
+
self._token = token
|
|
498
|
+
self._channel_options = channel_options
|
|
499
|
+
# The value is always validated, but ``compression`` only applies to
|
|
500
|
+
# remote connections; local mode stays on the (uncompressed) default.
|
|
501
|
+
normalized = _normalize_compression(compression)
|
|
502
|
+
if daemon is None and shared_daemon is None:
|
|
503
|
+
_check_compression_channel_option_conflict(
|
|
504
|
+
self._channel_options, normalized
|
|
505
|
+
)
|
|
506
|
+
self._compression = normalized
|
|
507
|
+
self._compression_label: CompressionOption = (
|
|
508
|
+
compression if compression is not None else "none"
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
# Local mode never enables compression; an explicit value is
|
|
512
|
+
# validated above and then ignored (source compatibility).
|
|
513
|
+
self._compression = None
|
|
514
|
+
self._compression_label = "none"
|
|
515
|
+
self._daemon = daemon
|
|
516
|
+
self._shared_daemon = shared_daemon
|
|
517
|
+
if daemon is not None and shared_daemon is not None:
|
|
518
|
+
raise InvalidArgumentError("daemon and shared_daemon are mutually exclusive")
|
|
519
|
+
# ``_conn`` is built in start(): a local client learns the daemon's
|
|
520
|
+
# random loopback port only after the daemon announces readiness.
|
|
521
|
+
self._conn: _Connection | None = None
|
|
522
|
+
self._queues: dict[str, TaskQueue] = {}
|
|
523
|
+
self._workers: set[Worker] = set()
|
|
524
|
+
self._started = False
|
|
525
|
+
self._closed = False
|
|
526
|
+
self._start_lock = asyncio.Lock()
|
|
527
|
+
self._close_lock = asyncio.Lock()
|
|
528
|
+
self._close_task: asyncio.Task[None] | None = None
|
|
529
|
+
self._daemon_restart_count = 0
|
|
530
|
+
|
|
531
|
+
# -- lifecycle ---------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
async def start(self) -> "RingoClient":
|
|
534
|
+
async with self._start_lock:
|
|
535
|
+
if self._closed:
|
|
536
|
+
raise RingoError("client is closed")
|
|
537
|
+
if not self._started:
|
|
538
|
+
try:
|
|
539
|
+
for attempt in range(2 if self._shared_daemon is not None else 1):
|
|
540
|
+
if self._daemon is not None:
|
|
541
|
+
self._endpoint = await self._daemon.start()
|
|
542
|
+
elif self._shared_daemon is not None:
|
|
543
|
+
self._endpoint = await self._shared_daemon.start()
|
|
544
|
+
conn = _Connection(
|
|
545
|
+
self._endpoint or "",
|
|
546
|
+
self._token,
|
|
547
|
+
local_token=(self._shared_daemon.token if self._shared_daemon else None),
|
|
548
|
+
local_generation=(self._shared_daemon.generation if self._shared_daemon else None),
|
|
549
|
+
channel_options=self._channel_options,
|
|
550
|
+
compression=self._compression,
|
|
551
|
+
)
|
|
552
|
+
self._conn = conn
|
|
553
|
+
conn.on_reconnect = self._reconnect
|
|
554
|
+
if self._shared_daemon is not None:
|
|
555
|
+
conn.set_shared_session(
|
|
556
|
+
self._shared_daemon.token or "",
|
|
557
|
+
self._shared_daemon.generation or "",
|
|
558
|
+
self._shared_stream_lost,
|
|
559
|
+
)
|
|
560
|
+
try:
|
|
561
|
+
await conn.connect(
|
|
562
|
+
check_health=self._daemon is not None or self._shared_daemon is not None
|
|
563
|
+
)
|
|
564
|
+
break
|
|
565
|
+
except Exception:
|
|
566
|
+
await conn.close()
|
|
567
|
+
self._conn = None
|
|
568
|
+
if self._shared_daemon is None or attempt > 0:
|
|
569
|
+
raise
|
|
570
|
+
# Stale PID/port, wrong token or generation is never
|
|
571
|
+
# accepted as identity. Re-elect once, boundedly.
|
|
572
|
+
self._shared_daemon.invalidate_current()
|
|
573
|
+
except BaseException:
|
|
574
|
+
# Fast cleanup: never leak a half-started channel or local daemon.
|
|
575
|
+
failed_conn = self._conn
|
|
576
|
+
self._conn = None
|
|
577
|
+
if failed_conn is not None:
|
|
578
|
+
await failed_conn.close()
|
|
579
|
+
await self._stop_daemon()
|
|
580
|
+
raise
|
|
581
|
+
self._started = True
|
|
582
|
+
return self
|
|
583
|
+
|
|
584
|
+
def _require_conn(self) -> _Connection:
|
|
585
|
+
conn = self._conn
|
|
586
|
+
if conn is None:
|
|
587
|
+
raise RingoError(
|
|
588
|
+
"client is not started; use 'async with' or await client.start()"
|
|
589
|
+
)
|
|
590
|
+
return conn
|
|
591
|
+
|
|
592
|
+
async def _reconnect(self) -> None:
|
|
593
|
+
"""Reconnect after a failed transport and negotiate a fresh session."""
|
|
594
|
+
conn = self._require_conn()
|
|
595
|
+
daemon = self._daemon
|
|
596
|
+
shared = self._shared_daemon
|
|
597
|
+
if daemon is not None and daemon.is_dead:
|
|
598
|
+
if self._daemon_restart_count >= _MAX_DAEMON_RESTARTS:
|
|
599
|
+
raise UnavailableError(
|
|
600
|
+
f"local daemon restart budget ({_MAX_DAEMON_RESTARTS}) exhausted"
|
|
601
|
+
)
|
|
602
|
+
self._daemon_restart_count += 1
|
|
603
|
+
delay = 0.3 * (2 ** (self._daemon_restart_count - 1))
|
|
604
|
+
await asyncio.sleep(delay)
|
|
605
|
+
endpoint = await daemon.start()
|
|
606
|
+
await conn.reset(endpoint)
|
|
607
|
+
elif shared is not None:
|
|
608
|
+
try:
|
|
609
|
+
endpoint = await shared.refresh()
|
|
610
|
+
await conn.reset(endpoint)
|
|
611
|
+
conn.set_shared_session(
|
|
612
|
+
shared.token or "", shared.generation or "", self._shared_stream_lost
|
|
613
|
+
)
|
|
614
|
+
await conn.attach_local()
|
|
615
|
+
await conn.negotiate()
|
|
616
|
+
except Exception:
|
|
617
|
+
shared.invalidate_current()
|
|
618
|
+
raise
|
|
619
|
+
return
|
|
620
|
+
await conn.negotiate()
|
|
621
|
+
|
|
622
|
+
async def _shared_stream_lost(self) -> None:
|
|
623
|
+
"""Proactively reconnect an idle shared client after AttachLocal EOF."""
|
|
624
|
+
if self._closed or not self._started or self._shared_daemon is None:
|
|
625
|
+
return
|
|
626
|
+
conn = self._conn
|
|
627
|
+
if conn is None or conn._closed:
|
|
628
|
+
return
|
|
629
|
+
# A stream can report EOF more than once while a replacement is being
|
|
630
|
+
# opened. Serialize recovery through the connection's normal lock.
|
|
631
|
+
for attempt in range(_MAX_DAEMON_RESTARTS + 1):
|
|
632
|
+
if self._closed:
|
|
633
|
+
return
|
|
634
|
+
try:
|
|
635
|
+
await asyncio.sleep(_BACKOFF_SECONDS[min(attempt, len(_BACKOFF_SECONDS) - 1)])
|
|
636
|
+
async with conn._lock:
|
|
637
|
+
await self._reconnect()
|
|
638
|
+
return
|
|
639
|
+
except Exception:
|
|
640
|
+
continue
|
|
641
|
+
|
|
642
|
+
async def __aenter__(self) -> "RingoClient":
|
|
643
|
+
return await self.start()
|
|
644
|
+
|
|
645
|
+
async def close(self) -> None:
|
|
646
|
+
"""Drain and stop once; caller cancellation cannot cancel cleanup."""
|
|
647
|
+
async with self._close_lock:
|
|
648
|
+
if self._close_task is None:
|
|
649
|
+
self._closed = True
|
|
650
|
+
self._close_task = asyncio.create_task(
|
|
651
|
+
self._close_impl(), name="ringo-client-close"
|
|
652
|
+
)
|
|
653
|
+
self._close_task.add_done_callback(_consume_task_exception)
|
|
654
|
+
close_task = self._close_task
|
|
655
|
+
await asyncio.shield(close_task)
|
|
656
|
+
|
|
657
|
+
async def _close_impl(self) -> None:
|
|
658
|
+
"""Attempt every cleanup step and aggregate any failures."""
|
|
659
|
+
errors: list[Exception] = []
|
|
660
|
+
# 1. Drain workers (stop taking tasks, wait grace, cancel remainder).
|
|
661
|
+
for worker in list(self._workers):
|
|
662
|
+
try:
|
|
663
|
+
await worker.close()
|
|
664
|
+
except Exception as exc: # keep cleaning up the rest
|
|
665
|
+
errors.append(exc)
|
|
666
|
+
self._workers.clear()
|
|
667
|
+
# 2. Close the gRPC channel.
|
|
668
|
+
conn, self._conn = self._conn, None
|
|
669
|
+
if conn is not None:
|
|
670
|
+
try:
|
|
671
|
+
await conn.close()
|
|
672
|
+
except Exception as exc:
|
|
673
|
+
errors.append(exc)
|
|
674
|
+
# 3. Shut the local daemon down via the lifecycle pipe, then wait.
|
|
675
|
+
try:
|
|
676
|
+
await self._stop_daemon()
|
|
677
|
+
except Exception as exc:
|
|
678
|
+
errors.append(exc)
|
|
679
|
+
if errors:
|
|
680
|
+
raise ExceptionGroup("errors while closing Ringo client", errors)
|
|
681
|
+
|
|
682
|
+
async def _stop_daemon(self) -> None:
|
|
683
|
+
daemon, self._daemon = self._daemon, None
|
|
684
|
+
if daemon is not None:
|
|
685
|
+
await daemon.stop()
|
|
686
|
+
|
|
687
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
688
|
+
try:
|
|
689
|
+
await self.close()
|
|
690
|
+
except ExceptionGroup as cleanup_errors:
|
|
691
|
+
# A business exception takes precedence; cleanup failures must
|
|
692
|
+
# not mask it, but should not vanish either.
|
|
693
|
+
if exc is None:
|
|
694
|
+
raise
|
|
695
|
+
for error in cleanup_errors.exceptions:
|
|
696
|
+
exc.__cause__ = exc.__cause__ or error
|
|
697
|
+
|
|
698
|
+
@property
|
|
699
|
+
def is_local(self) -> bool:
|
|
700
|
+
return self._daemon is not None or self._shared_daemon is not None
|
|
701
|
+
|
|
702
|
+
@property
|
|
703
|
+
def compression(self) -> CompressionOption:
|
|
704
|
+
"""The effective channel compression: ``"none"`` (default) or ``"gzip"``."""
|
|
705
|
+
return self._compression_label
|
|
706
|
+
|
|
707
|
+
# -- queues ------------------------------------------------------------
|
|
708
|
+
|
|
709
|
+
def queue(self, name: str) -> "TaskQueue":
|
|
710
|
+
if not name:
|
|
711
|
+
raise InvalidArgumentError("queue name is required")
|
|
712
|
+
if self._closed:
|
|
713
|
+
raise RingoError("client is closed")
|
|
714
|
+
existing = self._queues.get(name)
|
|
715
|
+
if existing is None:
|
|
716
|
+
existing = TaskQueue(self, name)
|
|
717
|
+
self._queues[name] = existing
|
|
718
|
+
return existing
|
|
719
|
+
|
|
720
|
+
def _register_worker(self, worker: "Worker") -> None:
|
|
721
|
+
self._workers.add(worker)
|
|
722
|
+
|
|
723
|
+
def _unregister_worker(self, worker: "Worker") -> None:
|
|
724
|
+
self._workers.discard(worker)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
class Ringo:
|
|
728
|
+
"""Factory for local (embedded daemon) and remote clients.
|
|
729
|
+
|
|
730
|
+
Both factories are synchronous and return a client whose
|
|
731
|
+
``async with``/``await start()`` performs the actual connect and
|
|
732
|
+
negotiation, so ``async with Ringo.local(...)`` works directly.
|
|
733
|
+
"""
|
|
734
|
+
|
|
735
|
+
@staticmethod
|
|
736
|
+
def local(
|
|
737
|
+
data_dir: str | os.PathLike[str],
|
|
738
|
+
storage: str | SQLiteStorage | PostgresStorage = "sqlite",
|
|
739
|
+
daemon_path: str | os.PathLike[str] | None = None,
|
|
740
|
+
*,
|
|
741
|
+
shared: bool = False,
|
|
742
|
+
startup_timeout: float = 15.0,
|
|
743
|
+
stop_timeout: float = 10.0,
|
|
744
|
+
) -> RingoClient:
|
|
745
|
+
"""Start a private daemon, or opt into a shared SQLite daemon."""
|
|
746
|
+
if not isinstance(shared, bool):
|
|
747
|
+
raise InvalidArgumentError("shared must be a boolean")
|
|
748
|
+
if storage == "sqlite" or isinstance(storage, SQLiteStorage):
|
|
749
|
+
storage_name = "sqlite"
|
|
750
|
+
postgres_dsn = None
|
|
751
|
+
elif isinstance(storage, PostgresStorage):
|
|
752
|
+
storage_name = "postgres"
|
|
753
|
+
postgres_dsn = storage.dsn
|
|
754
|
+
else:
|
|
755
|
+
raise InvalidArgumentError(f"unsupported local storage {storage!r}")
|
|
756
|
+
if shared and storage_name != "sqlite":
|
|
757
|
+
raise InvalidArgumentError("shared local mode supports SQLite only")
|
|
758
|
+
# Imported here so remote-only users never pay for subprocess imports.
|
|
759
|
+
if shared:
|
|
760
|
+
from .shared import SharedLocalDaemon
|
|
761
|
+
|
|
762
|
+
shared_daemon = SharedLocalDaemon(
|
|
763
|
+
data_dir, daemon_path=daemon_path, startup_timeout=startup_timeout
|
|
764
|
+
)
|
|
765
|
+
return RingoClient(shared_daemon=shared_daemon)
|
|
766
|
+
from .daemon import LocalDaemon
|
|
767
|
+
|
|
768
|
+
daemon = LocalDaemon(
|
|
769
|
+
data_dir,
|
|
770
|
+
storage=storage_name,
|
|
771
|
+
postgres_dsn=postgres_dsn,
|
|
772
|
+
daemon_path=daemon_path,
|
|
773
|
+
startup_timeout=startup_timeout,
|
|
774
|
+
stop_timeout=stop_timeout,
|
|
775
|
+
)
|
|
776
|
+
return RingoClient(daemon=daemon)
|
|
777
|
+
|
|
778
|
+
@staticmethod
|
|
779
|
+
def connect(
|
|
780
|
+
endpoint: str,
|
|
781
|
+
token: str | None = None,
|
|
782
|
+
*,
|
|
783
|
+
channel_options: Iterable[tuple[str, Any]] | None = None,
|
|
784
|
+
compression: CompressionOption | None = None,
|
|
785
|
+
) -> RingoClient:
|
|
786
|
+
"""Create a client for a remote (or already-running) endpoint.
|
|
787
|
+
|
|
788
|
+
``compression`` is an optional whole-channel setting: ``"none"``
|
|
789
|
+
(the default) or ``"gzip"``. ``None`` means "not specified", leaving
|
|
790
|
+
the gRPC channel default (and any raw ``channel_options`` compression
|
|
791
|
+
entry) in charge. An explicit value combined with the raw channel
|
|
792
|
+
option ``grpc.default_compression_algorithm`` raises
|
|
793
|
+
:class:`InvalidArgumentError`; any other value does so as well,
|
|
794
|
+
immediately and before any network activity.
|
|
795
|
+
"""
|
|
796
|
+
return RingoClient(
|
|
797
|
+
endpoint=endpoint,
|
|
798
|
+
token=token,
|
|
799
|
+
channel_options=channel_options,
|
|
800
|
+
compression=compression,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# ---------------------------------------------------------------------------
|
|
805
|
+
# TaskQueue
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
Handler = Callable[[Task, Any], Coroutine[Any, Any, None]]
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
class TaskQueue:
|
|
812
|
+
"""A client bound to one logical queue (``queue + uniqueKey`` dedupe)."""
|
|
813
|
+
|
|
814
|
+
def __init__(self, client: RingoClient, name: str) -> None:
|
|
815
|
+
self._client = client
|
|
816
|
+
self.name = name
|
|
817
|
+
|
|
818
|
+
# -- enqueue ------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
async def enqueue(
|
|
821
|
+
self,
|
|
822
|
+
spec: TaskSpec | None = None,
|
|
823
|
+
/,
|
|
824
|
+
**kwargs: Any,
|
|
825
|
+
) -> EnqueueResult:
|
|
826
|
+
"""Enqueue one task; duplicates return ``created=False`` (no raise)."""
|
|
827
|
+
if spec is None:
|
|
828
|
+
spec = TaskSpec(**kwargs)
|
|
829
|
+
request = queue_pb2.EnqueueRequest(queue=self.name, task=spec.to_proto())
|
|
830
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
831
|
+
response = await self._client._require_conn().call("Enqueue", request)
|
|
832
|
+
return EnqueueResult.from_proto(response.result)
|
|
833
|
+
|
|
834
|
+
async def enqueue_many(self, specs: Iterable[TaskSpec]) -> BatchResult:
|
|
835
|
+
"""Enqueue any number of tasks, auto-batching under the server limit.
|
|
836
|
+
|
|
837
|
+
The server enforces a hard limit of ``_MAX_ENQUEUE_BATCH`` (1000)
|
|
838
|
+
tasks per gRPC call. This method transparently splits larger
|
|
839
|
+
iterables into sequential sub-batches, preserving creation order,
|
|
840
|
+
and aggregates the per-item results into a single :class:`BatchResult`.
|
|
841
|
+
|
|
842
|
+
Fail-fast: if any sub-batch fails, the exception propagates and no
|
|
843
|
+
partial result is returned.
|
|
844
|
+
"""
|
|
845
|
+
specs = list(specs)
|
|
846
|
+
all_results: list[EnqueueResult] = []
|
|
847
|
+
total_created = 0
|
|
848
|
+
total_duplicates = 0
|
|
849
|
+
|
|
850
|
+
for start in range(0, len(specs), _MAX_ENQUEUE_BATCH):
|
|
851
|
+
chunk = specs[start : start + _MAX_ENQUEUE_BATCH]
|
|
852
|
+
request = queue_pb2.EnqueueManyRequest(
|
|
853
|
+
queue=self.name, tasks=[s.to_proto() for s in chunk]
|
|
854
|
+
)
|
|
855
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
856
|
+
response = await self._client._require_conn().call("EnqueueMany", request)
|
|
857
|
+
all_results.extend(EnqueueResult.from_proto(r) for r in response.results)
|
|
858
|
+
total_created += response.created_count
|
|
859
|
+
total_duplicates += response.duplicate_count
|
|
860
|
+
|
|
861
|
+
return BatchResult(
|
|
862
|
+
results=all_results,
|
|
863
|
+
created_count=total_created,
|
|
864
|
+
duplicate_count=total_duplicates,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
# -- queries ------------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
async def get_by_id(self, task_id: str) -> Task:
|
|
870
|
+
if not task_id:
|
|
871
|
+
raise InvalidArgumentError("task_id is required")
|
|
872
|
+
request = queue_pb2.GetTaskRequest(queue=self.name, task_id=task_id)
|
|
873
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
874
|
+
response = await self._client._require_conn().call(
|
|
875
|
+
"GetTask", request, retry_safe=True
|
|
876
|
+
)
|
|
877
|
+
return Task.from_proto(response.task)
|
|
878
|
+
|
|
879
|
+
async def get_by_unique_key(self, unique_key: str) -> Task:
|
|
880
|
+
if not unique_key:
|
|
881
|
+
raise InvalidArgumentError("unique_key is required")
|
|
882
|
+
request = queue_pb2.GetTaskRequest(queue=self.name, unique_key=unique_key)
|
|
883
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
884
|
+
response = await self._client._require_conn().call(
|
|
885
|
+
"GetTask", request, retry_safe=True
|
|
886
|
+
)
|
|
887
|
+
return Task.from_proto(response.task)
|
|
888
|
+
|
|
889
|
+
async def retry(self, unique_key: str) -> Task:
|
|
890
|
+
"""Reactivate a SUCCEEDED/DEAD task by its business unique key.
|
|
891
|
+
|
|
892
|
+
This always resolves through the proto ``unique_key`` field; use
|
|
893
|
+
:meth:`retry_by_id` for the task-ID form. No string is ever guessed.
|
|
894
|
+
"""
|
|
895
|
+
if not unique_key:
|
|
896
|
+
raise InvalidArgumentError("unique_key is required")
|
|
897
|
+
request = queue_pb2.RetryTaskRequest(queue=self.name)
|
|
898
|
+
request.unique_key = unique_key
|
|
899
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
900
|
+
response = await self._client._require_conn().call("RetryTask", request)
|
|
901
|
+
return Task.from_proto(response.task)
|
|
902
|
+
|
|
903
|
+
async def retry_by_id(self, task_id: str) -> Task:
|
|
904
|
+
"""Reactivate a SUCCEEDED/DEAD task by its server task ID."""
|
|
905
|
+
if not task_id:
|
|
906
|
+
raise InvalidArgumentError("task_id is required")
|
|
907
|
+
request = queue_pb2.RetryTaskRequest(queue=self.name)
|
|
908
|
+
request.task_id = task_id
|
|
909
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
910
|
+
response = await self._client._require_conn().call("RetryTask", request)
|
|
911
|
+
return Task.from_proto(response.task)
|
|
912
|
+
|
|
913
|
+
# -- manual claim ---------------------------------------------------------
|
|
914
|
+
|
|
915
|
+
async def claim(
|
|
916
|
+
self,
|
|
917
|
+
limit: int = 1,
|
|
918
|
+
*,
|
|
919
|
+
task_types: Iterable[str] | None = None,
|
|
920
|
+
wait_timeout: timedelta | None = None,
|
|
921
|
+
lease_duration: timedelta | None = None,
|
|
922
|
+
worker_id: str | None = None,
|
|
923
|
+
) -> list["LeasedTask"]:
|
|
924
|
+
"""Manually claim tasks (advanced API; not the default worker path)."""
|
|
925
|
+
if limit < 1 or limit > _MAX_ENQUEUE_BATCH:
|
|
926
|
+
raise InvalidArgumentError(
|
|
927
|
+
f"limit must be between 1 and {_MAX_ENQUEUE_BATCH}"
|
|
928
|
+
)
|
|
929
|
+
if wait_timeout is not None and wait_timeout < timedelta(0):
|
|
930
|
+
raise InvalidArgumentError("wait_timeout must not be negative")
|
|
931
|
+
if lease_duration is not None and lease_duration < timedelta(0):
|
|
932
|
+
raise InvalidArgumentError("lease_duration must not be negative")
|
|
933
|
+
request = queue_pb2.ClaimRequest(
|
|
934
|
+
queue=self.name,
|
|
935
|
+
worker_id=worker_id or new_worker_id(),
|
|
936
|
+
task_types=list(task_types or []),
|
|
937
|
+
limit=limit,
|
|
938
|
+
)
|
|
939
|
+
if wait_timeout is not None:
|
|
940
|
+
request.wait_timeout.CopyFrom(_pb.td_to_dur(wait_timeout))
|
|
941
|
+
if lease_duration is not None:
|
|
942
|
+
request.lease_duration.CopyFrom(_pb.td_to_dur(lease_duration))
|
|
943
|
+
request.context.CopyFrom(self._client._require_conn().request_context())
|
|
944
|
+
response = await self._client._require_conn().call("Claim", request)
|
|
945
|
+
return [LeasedTask(self, t) for t in response.tasks]
|
|
946
|
+
|
|
947
|
+
# -- worker ---------------------------------------------------------------
|
|
948
|
+
|
|
949
|
+
def worker(
|
|
950
|
+
self,
|
|
951
|
+
handler: Handler,
|
|
952
|
+
*,
|
|
953
|
+
task_types: Iterable[str] | None = None,
|
|
954
|
+
concurrency: int | ResourceConcurrency | AdaptiveConcurrency = 1,
|
|
955
|
+
weight: int = 1,
|
|
956
|
+
lease_duration: timedelta = timedelta(seconds=60),
|
|
957
|
+
grace_period: timedelta = timedelta(seconds=30),
|
|
958
|
+
on_idle: Callable[[], Any] | None = None,
|
|
959
|
+
) -> "Worker":
|
|
960
|
+
"""Create a credit-controlled worker over the frozen Work stream.
|
|
961
|
+
|
|
962
|
+
``on_idle`` is an optional zero-argument callable (sync or awaitable)
|
|
963
|
+
for bounded batch runs: once the worker has registered and sent its
|
|
964
|
+
initial credit, the SDK polls queue activity every 250 ms via the
|
|
965
|
+
read-only ``GetQueueActivity`` RPC scoped to this worker's
|
|
966
|
+
``task_types``; after the activity count has been continuously zero
|
|
967
|
+
for at least 1 s the callback fires exactly once, inline on the
|
|
968
|
+
``run()`` coroutine. A callback that returns normally leaves the
|
|
969
|
+
worker running (and never fires again); any exception it raises —
|
|
970
|
+
including ``SystemExit`` — propagates verbatim out of
|
|
971
|
+
``await worker.run()`` without becoming a task NACK. Workers without
|
|
972
|
+
``on_idle`` never send activity RPCs and stay compatible with
|
|
973
|
+
protocol minor 0 servers; ``on_idle`` requires minor >= 1 and raises
|
|
974
|
+
:class:`IncompatibleVersionError` from ``run()`` otherwise. Never
|
|
975
|
+
call ``worker.close()`` from inside the callback (it would wait on
|
|
976
|
+
the very coroutine executing it).
|
|
977
|
+
"""
|
|
978
|
+
from .worker import Worker
|
|
979
|
+
|
|
980
|
+
if not callable(handler):
|
|
981
|
+
raise InvalidArgumentError("handler must be callable")
|
|
982
|
+
if on_idle is not None and not callable(on_idle):
|
|
983
|
+
raise InvalidArgumentError("on_idle must be callable")
|
|
984
|
+
if isinstance(concurrency, bool) or not isinstance(
|
|
985
|
+
concurrency, (int, ResourceConcurrency, AdaptiveConcurrency)
|
|
986
|
+
):
|
|
987
|
+
raise InvalidArgumentError(
|
|
988
|
+
"concurrency must be an integer, ResourceConcurrency, or AdaptiveConcurrency"
|
|
989
|
+
)
|
|
990
|
+
if isinstance(concurrency, int) and (concurrency < 1 or concurrency > 2**31 - 1):
|
|
991
|
+
raise InvalidArgumentError(
|
|
992
|
+
"initial fixed concurrency must be between 1 and 2**31-1"
|
|
993
|
+
)
|
|
994
|
+
if isinstance(weight, bool) or not isinstance(weight, int) or weight < 1:
|
|
995
|
+
raise InvalidArgumentError("weight must be a positive integer")
|
|
996
|
+
if lease_duration < timedelta(0):
|
|
997
|
+
raise InvalidArgumentError("lease_duration must not be negative")
|
|
998
|
+
if grace_period < timedelta(0):
|
|
999
|
+
raise InvalidArgumentError("grace_period must not be negative")
|
|
1000
|
+
worker = Worker(
|
|
1001
|
+
self,
|
|
1002
|
+
handler,
|
|
1003
|
+
task_types=list(task_types or []),
|
|
1004
|
+
concurrency=concurrency,
|
|
1005
|
+
lease_duration=lease_duration,
|
|
1006
|
+
grace_period=grace_period,
|
|
1007
|
+
on_idle=on_idle,
|
|
1008
|
+
weight=weight,
|
|
1009
|
+
)
|
|
1010
|
+
self._client._register_worker(worker)
|
|
1011
|
+
return worker
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
# ---------------------------------------------------------------------------
|
|
1015
|
+
# Manual lease
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
class LeasedTask:
|
|
1019
|
+
"""A task claimed via :meth:`TaskQueue.claim` with its current lease."""
|
|
1020
|
+
|
|
1021
|
+
def __init__(self, queue: TaskQueue, msg: queue_pb2.LeasedTask) -> None:
|
|
1022
|
+
self._queue = queue
|
|
1023
|
+
self.task = Task.from_proto(msg.task)
|
|
1024
|
+
self.attempt: int = msg.lease.attempt
|
|
1025
|
+
self.worker_id: str = msg.lease.worker_id
|
|
1026
|
+
self._token: str = msg.lease.lease_token
|
|
1027
|
+
self._lease_until = _pb.ts_to_dt(msg.lease.until)
|
|
1028
|
+
|
|
1029
|
+
@property
|
|
1030
|
+
def lease_until(self):
|
|
1031
|
+
return self._lease_until
|
|
1032
|
+
|
|
1033
|
+
def _ref(self) -> queue_pb2.LeaseRef:
|
|
1034
|
+
return queue_pb2.LeaseRef(
|
|
1035
|
+
task_id=self.task.id,
|
|
1036
|
+
attempt=self.attempt,
|
|
1037
|
+
lease_token=self._token,
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
async def _terminal(self, name: str, request: Any) -> None:
|
|
1041
|
+
request.lease.CopyFrom(self._ref())
|
|
1042
|
+
conn = self._queue._client._require_conn()
|
|
1043
|
+
request.context.CopyFrom(conn.request_context())
|
|
1044
|
+
await conn.call(name, request)
|
|
1045
|
+
|
|
1046
|
+
async def ack(self) -> None:
|
|
1047
|
+
"""Acknowledge success for the current attempt/lease token."""
|
|
1048
|
+
await self._terminal("Ack", queue_pb2.AckRequest())
|
|
1049
|
+
|
|
1050
|
+
async def nack(self, error: str = "", *, delay: timedelta | None = None) -> None:
|
|
1051
|
+
"""Negative-acknowledge; the task is retried per policy."""
|
|
1052
|
+
request = queue_pb2.NackRequest(error=error)
|
|
1053
|
+
if delay is not None:
|
|
1054
|
+
request.retry_delay_override.CopyFrom(_pb.td_to_dur(delay))
|
|
1055
|
+
await self._terminal("Nack", request)
|
|
1056
|
+
|
|
1057
|
+
async def reject(self, error: str = "") -> None:
|
|
1058
|
+
"""Permanently fail the task (DEAD) for the current attempt."""
|
|
1059
|
+
await self._terminal("Reject", queue_pb2.RejectRequest(error=error))
|
|
1060
|
+
|
|
1061
|
+
async def extend_lease(self, extension: timedelta) -> None:
|
|
1062
|
+
"""Extend the lease to ``server_now + extension`` (absolute reset)."""
|
|
1063
|
+
if extension <= timedelta(0):
|
|
1064
|
+
raise InvalidArgumentError("extension must be positive")
|
|
1065
|
+
request = queue_pb2.ExtendLeaseRequest()
|
|
1066
|
+
request.extension.CopyFrom(_pb.td_to_dur(extension))
|
|
1067
|
+
request.lease.CopyFrom(self._ref())
|
|
1068
|
+
conn = self._queue._client._require_conn()
|
|
1069
|
+
request.context.CopyFrom(conn.request_context())
|
|
1070
|
+
response = await conn.call("ExtendLease", request)
|
|
1071
|
+
self._lease_until = _pb.ts_to_dt(response.lease.until)
|