jetstream-api 0.6.8__tar.gz → 0.6.9__tar.gz
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.
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/PKG-INFO +13 -1
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/README.md +12 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/__init__.py +1 -1
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/admin.py +84 -5
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/admintopic.py +11 -4
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/common.py +4 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/msg.py +27 -0
- jetstream_api-0.6.9/ilink/partitioner.py +78 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/producer.py +263 -60
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/PKG-INFO +13 -1
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/SOURCES.txt +1 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/pyproject.toml +1 -1
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/LICENSE.txt +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/connectivity.py +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/exception.py +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/pattern.py +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/properties.py +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/py.typed +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/qmgr.py +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/topic.py +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/util.py +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/dependency_links.txt +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/requires.txt +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/top_level.txt +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/setup.cfg +0 -0
- {jetstream_api-0.6.8 → jetstream_api-0.6.9}/tests/test_example.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jetstream-api
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.9
|
|
4
4
|
Summary: Python client API for iLink M.O.M. (Message Oriented Middleware)
|
|
5
5
|
Author: snowjeans
|
|
6
6
|
License: Boost Software License - Version 1.0 - August 17th, 2003
|
|
@@ -54,6 +54,18 @@ Python으로 작성된 ILink 클라이언트 API입니다.
|
|
|
54
54
|
## 설치 방법
|
|
55
55
|
pip install jetstream-api
|
|
56
56
|
|
|
57
|
+
## 0.6.9 변경 - 파티션을 클라이언트가 정한다 (엔진 7.0.1.3401 이상 필요)
|
|
58
|
+
|
|
59
|
+
엔진이 더는 파티션을 고르지 않습니다. 이 판부터 producer 와 관리 API 단건 발행이 파티션을 정해 보냅니다(Kafka 와 같은 방식).
|
|
60
|
+
|
|
61
|
+
- **키가 있으면** Kafka 와 같은 murmur2 로 **UTF-8 키 바이트**를 해시해 정합니다. 같은 키는 파이썬·자바 어느 클라이언트든 같은 파티션으로 갑니다.
|
|
62
|
+
- **키가 없으면** producer 는 한 파티션에 `batchSize` 바이트씩 붙였다가 다음 파티션으로 넘깁니다(Kafka 3.3+ 균일 스티키). 관리 API 단건 발행은 토픽별 라운드로빈입니다.
|
|
63
|
+
- 토픽의 파티션 수는 처음 보낼 때 한 번 묻습니다. **알 수 없으면(없는 토픽 등) `send()` 가 예외를 던집니다.** `acks=0` 도 같습니다.
|
|
64
|
+
- 멱등 발행(`enableIdempotence(True)`)은 (토픽, 파티션)마다 번호를 매깁니다. 한 레코드가 거절돼도(너무 큼 등) 뒤 레코드는 계속 적재되고, 여러 건을 한 봉투에 묶어 보냅니다.
|
|
65
|
+
- `maxRequestSize`(기본 1MB)는 레코드 한 건과 **봉투 하나의 크기**를 함께 묶습니다.
|
|
66
|
+
|
|
67
|
+
★ 그 이전 엔진에서는 발행이 모두 실패합니다(파티션 수를 물을 수 없다). 그런 엔진에는 0.6.8 을 쓰십시오.
|
|
68
|
+
|
|
57
69
|
## 0.2.0 신규 - pub/sub 토픽
|
|
58
70
|
|
|
59
71
|
큐에 더해 **pub/sub 토픽**을 지원합니다. 발행된 메시지는 retention 정책이 지울 때까지 보존되어
|
|
@@ -8,6 +8,18 @@ Python으로 작성된 ILink 클라이언트 API입니다.
|
|
|
8
8
|
## 설치 방법
|
|
9
9
|
pip install jetstream-api
|
|
10
10
|
|
|
11
|
+
## 0.6.9 변경 - 파티션을 클라이언트가 정한다 (엔진 7.0.1.3401 이상 필요)
|
|
12
|
+
|
|
13
|
+
엔진이 더는 파티션을 고르지 않습니다. 이 판부터 producer 와 관리 API 단건 발행이 파티션을 정해 보냅니다(Kafka 와 같은 방식).
|
|
14
|
+
|
|
15
|
+
- **키가 있으면** Kafka 와 같은 murmur2 로 **UTF-8 키 바이트**를 해시해 정합니다. 같은 키는 파이썬·자바 어느 클라이언트든 같은 파티션으로 갑니다.
|
|
16
|
+
- **키가 없으면** producer 는 한 파티션에 `batchSize` 바이트씩 붙였다가 다음 파티션으로 넘깁니다(Kafka 3.3+ 균일 스티키). 관리 API 단건 발행은 토픽별 라운드로빈입니다.
|
|
17
|
+
- 토픽의 파티션 수는 처음 보낼 때 한 번 묻습니다. **알 수 없으면(없는 토픽 등) `send()` 가 예외를 던집니다.** `acks=0` 도 같습니다.
|
|
18
|
+
- 멱등 발행(`enableIdempotence(True)`)은 (토픽, 파티션)마다 번호를 매깁니다. 한 레코드가 거절돼도(너무 큼 등) 뒤 레코드는 계속 적재되고, 여러 건을 한 봉투에 묶어 보냅니다.
|
|
19
|
+
- `maxRequestSize`(기본 1MB)는 레코드 한 건과 **봉투 하나의 크기**를 함께 묶습니다.
|
|
20
|
+
|
|
21
|
+
★ 그 이전 엔진에서는 발행이 모두 실패합니다(파티션 수를 물을 수 없다). 그런 엔진에는 0.6.8 을 쓰십시오.
|
|
22
|
+
|
|
11
23
|
## 0.2.0 신규 - pub/sub 토픽
|
|
12
24
|
|
|
13
25
|
큐에 더해 **pub/sub 토픽**을 지원합니다. 발행된 메시지는 retention 정책이 지울 때까지 보존되어
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
+
import random
|
|
4
5
|
import time
|
|
5
6
|
import uuid
|
|
6
7
|
|
|
@@ -16,6 +17,7 @@ from .exception import (
|
|
|
16
17
|
ILTimeoutException,
|
|
17
18
|
)
|
|
18
19
|
from .msg import ILDataMsg, ILMsg, ILMsgUtil, ILReportMsg, JMSHeader
|
|
20
|
+
from .partitioner import partition_for_key
|
|
19
21
|
from .properties import (
|
|
20
22
|
ILClusterDefDiag,
|
|
21
23
|
ILClusterPeerDef,
|
|
@@ -1020,7 +1022,8 @@ class ILAdminService:
|
|
|
1020
1022
|
맞추지 않으므로 그대로 두면 이중화가 사라진다. 성공 직후
|
|
1021
1023
|
:meth:`getLastClusterWarning` 을 확인하라 - 전파에 실패한 주소가
|
|
1022
1024
|
``MIMQW_CLUSTER_NOT_PROPAGATED (호스트:포트)`` 로 남는다(둘 이상이면
|
|
1023
|
-
쉼표로 이어진다. **엔진 rev 3385
|
|
1025
|
+
쉼표로 이어진다. **엔진 rev 3385~3390** - rev 3391 이상은 서비스 간
|
|
1026
|
+
전파가 없어 이 경고도 오지 않는다).
|
|
1024
1027
|
|
|
1025
1028
|
Example::
|
|
1026
1029
|
|
|
@@ -1261,6 +1264,15 @@ class ILAdminService:
|
|
|
1261
1264
|
def getLastClusterWarning(self) -> str:
|
|
1262
1265
|
"""마지막 클러스터 변경이 남긴 **경고**. 없으면 빈 문자열.
|
|
1263
1266
|
|
|
1267
|
+
.. warning:: 엔진 rev 3391 이상에서는 **늘 빈 문자열이다**
|
|
1268
|
+
|
|
1269
|
+
클러스터 재설계 2단계(엔진 이력 218 CLSDEF2-1)에서 서비스 사이의 정의
|
|
1270
|
+
전파와 ``MIMQW_CLUSTER_NOT_PROPAGATED`` 가 함께 없어졌다. 정의는 판사만
|
|
1271
|
+
갖고, 판사 ilcc 가 조정자에게 내려 준다. 그래서 지금 엔진은 이 자리에
|
|
1272
|
+
경고를 싣지 않는다. **빈 문자열이 "다 심겼다" 는 뜻이 아니다** - 확인할
|
|
1273
|
+
것이 없어 비어 있을 뿐이다. 정의가 맞는지는 판사에서 ``DIAG CLUSTER`` 로
|
|
1274
|
+
본다. 아래 설명은 **rev 3385~3390 엔진**의 동작이다.
|
|
1275
|
+
|
|
1264
1276
|
:meth:`createCluster` / :meth:`removeCluster` / :meth:`setClusterProperty`
|
|
1265
1277
|
는 **성공했더라도** 정의를 세 서비스에 다 심지 못했을 수 있다. 그때 서버가
|
|
1266
1278
|
응답에 경고를 실어 보내는데, 이 메서드가 그 원문을 돌려준다.
|
|
@@ -3210,6 +3222,10 @@ class ILAdminQmgr:
|
|
|
3210
3222
|
#: 엔진 버전 문자열. 계열을 봐야 하는 게이트가 쓴다
|
|
3211
3223
|
#: (:meth:`ILC.is_set_homepath_safe`) - 리비전만으로는 계열이 안 갈린다.
|
|
3212
3224
|
self.version = version
|
|
3225
|
+
#: v0.6.9 IDEMP: 관리 API 단건 발행용 - 토픽별 파티션 수(TOPIC_METADATA 릴레이)와
|
|
3226
|
+
#: 키 없는 레코드의 라운드로빈 위치.
|
|
3227
|
+
self._topic_partitions: dict[str, int] = {}
|
|
3228
|
+
self._round_robin: dict[str, int] = {}
|
|
3213
3229
|
|
|
3214
3230
|
@staticmethod
|
|
3215
3231
|
def main(args: list[str]) -> None:
|
|
@@ -6463,7 +6479,7 @@ class ILAdminQmgr:
|
|
|
6463
6479
|
만 1~47바이트로 받고 ``.`` / ``..`` 는 거절한다(Kafka 토픽 이름 규칙과
|
|
6464
6480
|
같다). 그 이전 엔진은 47바이트 상한만 검사한다. 엔진은 **생성할 때만**
|
|
6465
6481
|
검사하므로 이미 있는 토픽은 이름과 상관없이 계속 쓸 수 있다.
|
|
6466
|
-
options (str): ``key=value`` 를
|
|
6482
|
+
options (str): ``key=value`` 를 ``;`` 로 이은 문자열. 기본 ``""`` 는
|
|
6467
6483
|
엔진 기본값이다. 예 ``"partitions=3"``, ``"retentionms=86400000"``.
|
|
6468
6484
|
**파티션 수는 만든 뒤 바꿀 수 없으니** 여기서 정해야 한다.
|
|
6469
6485
|
|
|
@@ -6536,7 +6552,7 @@ class ILAdminQmgr:
|
|
|
6536
6552
|
|
|
6537
6553
|
Args:
|
|
6538
6554
|
topicName (str): 대상 토픽 이름.
|
|
6539
|
-
options (str): ``key=value`` 를
|
|
6555
|
+
options (str): ``key=value`` 를 ``;`` 로 이은 문자열.
|
|
6540
6556
|
예 ``"retentionms=604800000"``. **파티션 수는 바꿀 수 없다.**
|
|
6541
6557
|
|
|
6542
6558
|
Raises:
|
|
@@ -6706,6 +6722,68 @@ class ILAdminQmgr:
|
|
|
6706
6722
|
"""
|
|
6707
6723
|
return self.service.inquire_data_or_report(self.qmgrName, code, ext, *args)
|
|
6708
6724
|
|
|
6725
|
+
def _partition_count(self, topicName: str, refresh: bool = False) -> int:
|
|
6726
|
+
"""토픽의 파티션 수(내부용). 처음 한 번 TOPIC_METADATA 를 릴레이로 묻고 이 핸들에 캐시한다.
|
|
6727
|
+
|
|
6728
|
+
flush 창의 ``MIMQE_TOPIC_FLUSHED`` 는 100ms 간격으로 5초까지 다시 묻는다(flush 는 파티션 수를
|
|
6729
|
+
바꾸지 않는다).
|
|
6730
|
+
"""
|
|
6731
|
+
if not refresh:
|
|
6732
|
+
n = self._topic_partitions.get(topicName)
|
|
6733
|
+
if n is not None:
|
|
6734
|
+
return n
|
|
6735
|
+
for attempt in range(51):
|
|
6736
|
+
res = self._relayTopic(ILC.MIMQ_TOPIC_METADATA, None, topicName, "", "")
|
|
6737
|
+
if res.getType() != ILC.MIMQ_REPORT_MESSAGE:
|
|
6738
|
+
raise ILOperationException(
|
|
6739
|
+
"MIMQE_INVALID_MSG_FORMAT (topic metadata : msg type %d)" % res.getType())
|
|
6740
|
+
rep = res.getReportMsg()
|
|
6741
|
+
if rep.getCode() == ILC.MIMQ_SUCCESSFUL_TOPIC_OPERATION:
|
|
6742
|
+
try:
|
|
6743
|
+
n = int((rep.getArg1() or "").strip())
|
|
6744
|
+
except ValueError:
|
|
6745
|
+
n = -1
|
|
6746
|
+
if n < 1:
|
|
6747
|
+
raise ILOperationException(
|
|
6748
|
+
"MIMQE_INVALID_MSG_FORMAT (topic metadata : partitions %r)" % rep.getArg1())
|
|
6749
|
+
self._topic_partitions[topicName] = n
|
|
6750
|
+
return n
|
|
6751
|
+
reason = rep.getArg2() or ""
|
|
6752
|
+
if "MIMQE_TOPIC_FLUSHED" in reason and attempt < 50:
|
|
6753
|
+
time.sleep(0.1)
|
|
6754
|
+
continue
|
|
6755
|
+
raise ILOperationException(reason or "topic metadata failed (code=%d)" % rep.getCode())
|
|
6756
|
+
|
|
6757
|
+
def _choose_partition(self, topicName: str, key: str, partition: int | None) -> int:
|
|
6758
|
+
"""관리 API 단건 발행의 파티션(내부용). 엔진은 명시 파티션이 없는 발행을 거절한다(IDEMP).
|
|
6759
|
+
|
|
6760
|
+
명시 파티션 > 키의 murmur2(UTF-8, producer 와 같은 :mod:`ilink.partitioner` - 같은 키는 같은
|
|
6761
|
+
파티션) > 키가 없으면 토픽별 라운드로빈. 한 번 부르면 레코드 하나라 producer 처럼 스티키로
|
|
6762
|
+
모을 것이 없다. 시작 위치는 무작위다 - 핸들을 새로 얻어 한 건씩 보내는 쓰임에서 늘 파티션 0 에
|
|
6763
|
+
몰리지 않게 한다.
|
|
6764
|
+
"""
|
|
6765
|
+
cached = topicName in self._topic_partitions
|
|
6766
|
+
n = self._partition_count(topicName)
|
|
6767
|
+
if partition is not None and partition >= 0:
|
|
6768
|
+
if partition >= n and cached:
|
|
6769
|
+
n = self._partition_count(topicName, refresh=True) # 다시 만들어져 늘었을 수 있다
|
|
6770
|
+
if partition >= n:
|
|
6771
|
+
raise ILOperationException(
|
|
6772
|
+
"MIMQE_INVALID_ARGUMENT (Partition : %d, Available : 0~%d)" % (partition, n - 1))
|
|
6773
|
+
return partition
|
|
6774
|
+
if key:
|
|
6775
|
+
return partition_for_key(key, n)
|
|
6776
|
+
p = self._round_robin.get(topicName)
|
|
6777
|
+
if p is None:
|
|
6778
|
+
p = random.randrange(n)
|
|
6779
|
+
p %= n
|
|
6780
|
+
self._round_robin[topicName] = (p + 1) % n
|
|
6781
|
+
return p
|
|
6782
|
+
|
|
6783
|
+
def _forget_partitions(self, topicName: str) -> None:
|
|
6784
|
+
"""토픽이 없거나 파티션 범위 오류면 캐시를 버린다(내부용). 다음 발행이 다시 묻는다."""
|
|
6785
|
+
self._topic_partitions.pop(topicName, None)
|
|
6786
|
+
|
|
6709
6787
|
def get_qmgr_name(self) -> str:
|
|
6710
6788
|
"""이 핸들이 다루는 큐 관리자 이름.
|
|
6711
6789
|
|
|
@@ -6816,8 +6894,9 @@ class ILAdminQmgr:
|
|
|
6816
6894
|
Args:
|
|
6817
6895
|
topicName (str): 대상 토픽 이름.
|
|
6818
6896
|
value (bytes): payload. 빈 값도 허용한다(컴팩션 tombstone).
|
|
6819
|
-
key (str): 파티션 라우팅 키. **UTF-8 69바이트 이하.**
|
|
6820
|
-
|
|
6897
|
+
key (str): 파티션 라우팅 키. **UTF-8 69바이트 이하.** 같은 키는 producer 와 같은
|
|
6898
|
+
파티션으로 간다. 비우면 토픽별 라운드로빈.
|
|
6899
|
+
partition (int | None): 파티션 직접 지정. None(기본)이면 키로 정한다.
|
|
6821
6900
|
|
|
6822
6901
|
Returns:
|
|
6823
6902
|
tuple[int, int, int]: ``(offset, timestamp, partition)``.
|
|
@@ -503,9 +503,11 @@ class ILAdminTopic:
|
|
|
503
503
|
Args:
|
|
504
504
|
value (bytes): payload. **빈 값도 허용**한다(컴팩션 tombstone).
|
|
505
505
|
key (str): 파티션 라우팅 키. **UTF-8 로 69바이트 이하**여야 한다.
|
|
506
|
-
빈 문자열(기본)이면
|
|
506
|
+
같은 키는 producer 와 같은 파티션으로 간다(murmur2, UTF-8). 빈 문자열(기본)이면
|
|
507
|
+
이 API 가 토픽별 라운드로빈으로 고른다.
|
|
507
508
|
partition (int | None): 파티션을 직접 고를 때 지정. None(기본)이면
|
|
508
|
-
키 해시나 라운드로빈으로
|
|
509
|
+
키 해시나 라운드로빈으로 이 API 가 정한다. 토픽의 파티션 수는 처음 한 번 묻고
|
|
510
|
+
관리 큐 관리자 핸들에 캐시한다.
|
|
509
511
|
|
|
510
512
|
Returns:
|
|
511
513
|
tuple[int, int, int]: ``(offset, timestamp, partition)``. timestamp 는
|
|
@@ -538,7 +540,9 @@ class ILAdminTopic:
|
|
|
538
540
|
"MIMQE_INVALID_ARGUMENT (key over %d bytes : %d)"
|
|
539
541
|
% (MAX_KEY_BYTES, len(key.encode("utf-8")))
|
|
540
542
|
)
|
|
541
|
-
|
|
543
|
+
# v0.6.9 IDEMP: 엔진은 명시 파티션이 없는 발행을 거절한다 - 파티션은 이 API 가 정해 싣는다.
|
|
544
|
+
part = self.adminQmgr._choose_partition(self.name, key or "", partition)
|
|
545
|
+
options = "partition=%d" % part
|
|
542
546
|
res = self.adminQmgr._relayTopic(
|
|
543
547
|
ILC.MIMQ_TOPIC_PUBLISH_ONE, bytes(value or b""),
|
|
544
548
|
self.name, key or "", options,
|
|
@@ -547,7 +551,10 @@ class ILAdminTopic:
|
|
|
547
551
|
raise ILMsgTypeException(res.getType(), "msg type must be [MIMQ_REPORT_MESSAGE]")
|
|
548
552
|
report = res.getReportMsg()
|
|
549
553
|
if report.getCode() != ILC.MIMQ_SUCCESSFUL_TOPIC_PUBLISH:
|
|
550
|
-
|
|
554
|
+
reason = report.getArg2() or ""
|
|
555
|
+
if "MIMQE_OBJECT_NOT_FOUND" in reason or "MIMQE_INVALID_ARGUMENT (Partition" in reason:
|
|
556
|
+
self.adminQmgr._forget_partitions(self.name) # 다시 만들어졌을 수 있다 - 다음 발행이 다시 묻는다
|
|
557
|
+
raise ILOperationException(reason)
|
|
551
558
|
return (int(report.getArg1() or "0"),
|
|
552
559
|
int(report.getArg2() or "0"),
|
|
553
560
|
int(report.getArg3() or "0"))
|
|
@@ -439,6 +439,10 @@ class ILC:
|
|
|
439
439
|
#: 관리 연결(ilsvc 릴레이)로 레코드 **한 건**을 발행한다. 엔진 rev 3295 신설.
|
|
440
440
|
#: arg1=토픽, arg2=키(<=69바이트), arg3="partition=N"(생략 가능), extension=페이로드.
|
|
441
441
|
MIMQ_TOPIC_PUBLISH_ONE = 3390
|
|
442
|
+
#: 토픽 메타데이터 조회(IDEMP, 엔진 이력 246). arg1=토픽. 성공 4156 이면 arg1=파티션 수,
|
|
443
|
+
#: arg2=epoch. 실패 4157 이면 arg2=사유(없는 토픽은 MIMQE_OBJECT_NOT_FOUND, flush 창이면
|
|
444
|
+
#: MIMQE_TOPIC_FLUSHED - 재시도할 수 있다). 데이터 연결과 관리 연결 릴레이 둘 다 받는다.
|
|
445
|
+
MIMQ_TOPIC_METADATA = 3397
|
|
442
446
|
|
|
443
447
|
# 토픽 응답 코드
|
|
444
448
|
MIMQ_SUCCESSFUL_TOPIC_OPERATION = 4156
|
|
@@ -819,8 +819,35 @@ class ILTopicFrameMsg(ILInnerMsg):
|
|
|
819
819
|
ba[msg_size] = 0x0A
|
|
820
820
|
return bytes(ba)
|
|
821
821
|
|
|
822
|
+
#: PUBLISH 프레임(헤더 포함) 안 고정 칸의 시작 위치. 헤더 25 + 토픽 70 + corrId 20 + ackMode 1 = 116.
|
|
823
|
+
PUBLISH_PARTITION_OFFSET = 116
|
|
824
|
+
PUBLISH_PID_OFFSET = 126
|
|
825
|
+
PUBLISH_SEQ_OFFSET = 146
|
|
826
|
+
|
|
827
|
+
@staticmethod
|
|
828
|
+
def patchPidSeq(buf: bytearray, frame_start: int, pid: int, seq: int) -> None:
|
|
829
|
+
"""직렬화된 PUBLISH 프레임의 pid/seq 칸을 **제자리에서** 덮어쓴다.
|
|
830
|
+
|
|
831
|
+
멱등 seq 는 봉투를 만들 때 매긴다(재번호 때문에 send 시점에는 정할 수 없다). 두 칸은
|
|
832
|
+
고정 위치라 프레임을 다시 직렬화하지 않고 바꿀 수 있다.
|
|
833
|
+
|
|
834
|
+
Args:
|
|
835
|
+
buf (bytearray): 프레임이 들어 있는 버퍼(봉투 조립 버퍼).
|
|
836
|
+
frame_start (int): 버퍼 안에서 그 프레임이 시작하는 위치(헤더 첫 바이트).
|
|
837
|
+
pid (int): producer id.
|
|
838
|
+
seq (int): 이 (토픽, 파티션)의 seq.
|
|
839
|
+
"""
|
|
840
|
+
for off, value in ((ILTopicFrameMsg.PUBLISH_PID_OFFSET, pid),
|
|
841
|
+
(ILTopicFrameMsg.PUBLISH_SEQ_OFFSET, seq)):
|
|
842
|
+
raw = str(value).encode("ascii")
|
|
843
|
+
if len(raw) > 20:
|
|
844
|
+
raise ValueError("pid/seq over 20 digits: %d" % value)
|
|
845
|
+
start = frame_start + off
|
|
846
|
+
buf[start:start + 20] = raw + b"\x00" * (20 - len(raw))
|
|
847
|
+
|
|
822
848
|
create_publish = createPublish
|
|
823
849
|
build_batch_publish_envelope = buildBatchPublishEnvelope
|
|
850
|
+
patch_pid_seq = patchPidSeq
|
|
824
851
|
|
|
825
852
|
# ── 발행 프레임 필드 지정 (Java 의 setter 대응) ──────────────────────
|
|
826
853
|
def setAckMode(self, ack_mode: str) -> None:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""토픽 파티셔너 - 키로 파티션 번호를 정한다(Kafka ``DefaultPartitioner`` 와 같은 규칙).
|
|
2
|
+
|
|
3
|
+
엔진은 발행 레코드의 파티션을 고르지 않는다(IDEMP 설계 v0.4, 사용자 결정). 발행 경로
|
|
4
|
+
(producer, 관리 API 단건 발행)가 **이 모듈 하나로** 파티션을 정해 명시 파티션으로 싣는다.
|
|
5
|
+
그래서 같은 키는 어느 경로로 보내도 같은 파티션으로 간다.
|
|
6
|
+
|
|
7
|
+
- 해시는 Kafka ``org.apache.kafka.common.utils.Utils.murmur2`` 와 비트 단위로 같다
|
|
8
|
+
(seed ``0x9747b28c``). 골든 벡터 ``tests/data/murmur2_golden.txt`` 는 Kafka 3.9 jar 에서 뽑았다.
|
|
9
|
+
- 키는 **UTF-8 바이트**로 해시한다. 자바 클라이언트도 같은 바이트를 쓴다.
|
|
10
|
+
- 파티션 = ``to_positive(murmur2(UTF-8 키)) % 파티션 수``.
|
|
11
|
+
|
|
12
|
+
**응용이 직접 부를 일은 없다** - 라이브러리 내부에서 쓴다.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
_M = 0x5BD1E995
|
|
18
|
+
_SEED = 0x9747B28C
|
|
19
|
+
_MASK = 0xFFFFFFFF
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def murmur2(data: bytes) -> int:
|
|
23
|
+
"""Kafka ``Utils.murmur2`` 와 같은 32비트 해시(부호 있는 정수)를 돌려준다.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
data (bytes): 해시할 바이트.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
int: -2^31 ~ 2^31-1 범위의 해시(자바 int 와 같은 값).
|
|
30
|
+
"""
|
|
31
|
+
length = len(data)
|
|
32
|
+
h = (_SEED ^ length) & _MASK
|
|
33
|
+
n4 = length // 4
|
|
34
|
+
for i in range(n4):
|
|
35
|
+
i4 = i * 4
|
|
36
|
+
k = data[i4] | (data[i4 + 1] << 8) | (data[i4 + 2] << 16) | (data[i4 + 3] << 24)
|
|
37
|
+
k = (k * _M) & _MASK
|
|
38
|
+
k ^= k >> 24
|
|
39
|
+
k = (k * _M) & _MASK
|
|
40
|
+
h = (h * _M) & _MASK
|
|
41
|
+
h ^= k
|
|
42
|
+
rem = length % 4
|
|
43
|
+
base = length & ~3
|
|
44
|
+
if rem >= 3:
|
|
45
|
+
h ^= data[base + 2] << 16
|
|
46
|
+
if rem >= 2:
|
|
47
|
+
h ^= data[base + 1] << 8
|
|
48
|
+
if rem >= 1:
|
|
49
|
+
h ^= data[base]
|
|
50
|
+
h = (h * _M) & _MASK
|
|
51
|
+
h ^= h >> 13
|
|
52
|
+
h = (h * _M) & _MASK
|
|
53
|
+
h ^= h >> 15
|
|
54
|
+
return h - (1 << 32) if h & 0x80000000 else h
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def to_positive(value: int) -> int:
|
|
58
|
+
"""Kafka ``Utils.toPositive`` - 부호 비트를 떼어 0 이상으로 만든다."""
|
|
59
|
+
return value & 0x7FFFFFFF
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def partition_for_key(key: str | bytes, num_partitions: int) -> int:
|
|
63
|
+
"""키가 있는 레코드의 파티션 번호.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
key (str | bytes): 레코드 키. 문자열이면 UTF-8 로 바꿔 해시한다.
|
|
67
|
+
num_partitions (int): 토픽의 파티션 수(1 이상).
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
int: 0 ~ num_partitions-1.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: 파티션 수가 1 미만일 때.
|
|
74
|
+
"""
|
|
75
|
+
if num_partitions < 1:
|
|
76
|
+
raise ValueError("num_partitions must be >= 1: %r" % (num_partitions,))
|
|
77
|
+
raw = key.encode("utf-8") if isinstance(key, str) else bytes(key)
|
|
78
|
+
return to_positive(murmur2(raw)) % num_partitions
|
|
@@ -11,14 +11,17 @@
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import random
|
|
15
|
+
import re
|
|
14
16
|
import threading
|
|
15
17
|
import time
|
|
16
18
|
import uuid
|
|
17
19
|
|
|
18
20
|
from .common import ILC
|
|
19
21
|
from .connectivity import TCPConnector
|
|
20
|
-
from .exception import ILException, ILOperationException
|
|
22
|
+
from .exception import ILException, ILOperationException, ILSessionException
|
|
21
23
|
from .msg import ILMsg, ILMsgUtil, ILTopicFrameMsg, encode_property
|
|
24
|
+
from .partitioner import partition_for_key
|
|
22
25
|
|
|
23
26
|
|
|
24
27
|
def _corr_id() -> str:
|
|
@@ -256,7 +259,8 @@ class ILProducerConfig:
|
|
|
256
259
|
self._maxBlockMs = max(0, n); return self
|
|
257
260
|
|
|
258
261
|
def maxRequestSize(self, n: int) -> "ILProducerConfig":
|
|
259
|
-
"""레코드 1건의 **프레임** 크기
|
|
262
|
+
"""레코드 1건의 **프레임** 크기 상한이자 **봉투 하나의 크기 상한**(바이트).
|
|
263
|
+
(기본 1048576 = 1MB, Kafka ``max.request.size`` 와 같은 뜻)
|
|
260
264
|
|
|
261
265
|
Args:
|
|
262
266
|
n (int): 프레임 크기 상한(**바이트**). **1024 미만은 1024 로 보정된다.**
|
|
@@ -271,6 +275,9 @@ class ILProducerConfig:
|
|
|
271
275
|
이 값 이하여도 토픽의 세그먼트 크기(SEGMENTBYTES)보다 큰 레코드는 서버가
|
|
272
276
|
받지 않는다(``acks=1`` 이면 그 레코드만 ``MIMQE_TOPIC_MESSAGE_TOO_LARGE`` 로
|
|
273
277
|
실패한다).
|
|
278
|
+
|
|
279
|
+
v0.6.9 부터 **봉투 하나(레코드 프레임의 합)도 이 값을 넘지 않게** 나눠 보낸다.
|
|
280
|
+
기본값이면 봉투가 약 4만 건을 넘지 않아 엔진의 봉투 건수 상한(10만 건)에 닿지 않는다.
|
|
274
281
|
"""
|
|
275
282
|
self._maxRequestSize = max(1024, n); return self
|
|
276
283
|
|
|
@@ -428,10 +435,10 @@ class ILProducerConfig:
|
|
|
428
435
|
return self._maxBlockMs
|
|
429
436
|
|
|
430
437
|
def get_max_request_size(self) -> int:
|
|
431
|
-
"""레코드 1건 프레임 상한(바이트). 기본 1048576.
|
|
438
|
+
"""레코드 1건 프레임 상한이자 봉투 크기 상한(바이트). 기본 1048576.
|
|
432
439
|
|
|
433
440
|
Returns:
|
|
434
|
-
int: 레코드 1건 프레임 상한(바이트). 기본 1048576.
|
|
441
|
+
int: 레코드 1건 프레임 상한이자 봉투 크기 상한(바이트). 기본 1048576.
|
|
435
442
|
"""
|
|
436
443
|
return self._maxRequestSize
|
|
437
444
|
|
|
@@ -478,11 +485,14 @@ class ILProducerRecord:
|
|
|
478
485
|
topic (str): 발행 대상 토픽 이름. **필수.** 와일드카드는 쓸 수 없다
|
|
479
486
|
(패턴은 구독 전용이다).
|
|
480
487
|
key (str): 파티셔닝 키. 기본 ``""``. 같은 키는 항상 같은 파티션으로
|
|
481
|
-
가므로 **키 단위 순서가 보장된다.**
|
|
488
|
+
가므로 **키 단위 순서가 보장된다.** 파티션은 producer 가 Kafka 와 같은
|
|
489
|
+
murmur2 로 **UTF-8 키 바이트**를 해시해 정한다. 비우면 producer 가 한
|
|
490
|
+
파티션에 ``batchSize`` 바이트씩 붙였다가 다음 파티션으로 넘긴다(균일 스티키).
|
|
482
491
|
value (bytes): payload. 기본 ``b""``. ``bytes``/``bytearray`` 가 아니면
|
|
483
492
|
``str()`` 후 UTF-8 로 인코딩한다.
|
|
484
493
|
partition (int): 파티션 번호를 직접 지정한다. 기본 ``-1`` = 미지정
|
|
485
|
-
(
|
|
494
|
+
(producer 가 키로 정한다). 토픽의 파티션 수 범위를 벗어나면 ``get()`` 이
|
|
495
|
+
``MIMQE_INVALID_ARGUMENT`` 로 실패한다.
|
|
486
496
|
properties (dict | None): 레코드에 딸려 가는 문자열 키/값 메타데이터.
|
|
487
497
|
기본 None. 구독측에서 ``ILTopicMsg.get_property(key)`` 로 읽는다.
|
|
488
498
|
순서를 유지하려면 ``dict`` 삽입 순서를 그대로 쓴다.
|
|
@@ -497,7 +507,7 @@ class ILProducerRecord:
|
|
|
497
507
|
"""파티션을 직접 지정한다.
|
|
498
508
|
|
|
499
509
|
Args:
|
|
500
|
-
n (int): 파티션 번호. ``-1`` 이면 미지정(
|
|
510
|
+
n (int): 파티션 번호. ``-1`` 이면 미지정(producer 가 키로 정한다).
|
|
501
511
|
|
|
502
512
|
Returns:
|
|
503
513
|
ILProducerRecord: 체이닝을 위해 자기 자신.
|
|
@@ -774,6 +784,27 @@ def _extract_leader_hint(exc: Exception) -> str | None:
|
|
|
774
784
|
return hint
|
|
775
785
|
|
|
776
786
|
|
|
787
|
+
#: INIT_PRODUCER_ID 로 맞추는 멱등 프로토콜 판. "2" = seq 는 (토픽, 파티션) 범위이고 모든 발행이
|
|
788
|
+
#: 명시 파티션을 싣는다(엔진 IDEMP, 이력 246).
|
|
789
|
+
_IDEM_PROTOCOL = "2"
|
|
790
|
+
#: 순서 오류 사유 ``MIMQE_OUT_OF_ORDER_SEQUENCE (expected n, got m)`` 에서 n 을 뽑는다.
|
|
791
|
+
_OOO_EXPECTED = re.compile(r"expected (\d+), got (\d+)")
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
class _Pending:
|
|
795
|
+
"""누적 버퍼의 레코드 1건. ``seq`` 는 봉투를 만들 때 매긴다(-1 = 아직 없음)."""
|
|
796
|
+
|
|
797
|
+
__slots__ = ("frame", "future", "record", "partition", "seq")
|
|
798
|
+
|
|
799
|
+
def __init__(self, frame: bytes, future: ILPublishFuture,
|
|
800
|
+
record: ILProducerRecord, partition: int) -> None:
|
|
801
|
+
self.frame = frame
|
|
802
|
+
self.future = future
|
|
803
|
+
self.record = record
|
|
804
|
+
self.partition = partition
|
|
805
|
+
self.seq = -1
|
|
806
|
+
|
|
807
|
+
|
|
777
808
|
class ILTopicProducer:
|
|
778
809
|
"""토픽 발행자. ``ILQmgr.create_producer()`` 로 얻는다.
|
|
779
810
|
|
|
@@ -814,9 +845,15 @@ class ILTopicProducer:
|
|
|
814
845
|
self._closed = False
|
|
815
846
|
self._connected = False
|
|
816
847
|
self._pid = -1
|
|
817
|
-
#: 멱등 seq -
|
|
818
|
-
#:
|
|
819
|
-
self.
|
|
848
|
+
#: 멱등 seq - **(토픽, 파티션)별**로 0 부터 센다. 엔진 appender 가 파티션마다 연속을 본다(IDEMP).
|
|
849
|
+
#: 봉투를 만들 때 매기고, 적재 안 됨이 확정된 건(건별 NACK)의 번호는 돌려받아 다시 매긴다.
|
|
850
|
+
self._seq_next: dict[tuple[str, int], int] = {}
|
|
851
|
+
#: (토픽, 파티션)에서 받아들여진(적재 또는 중복 응답) 가장 큰 seq. 재번호의 하한이다.
|
|
852
|
+
self._seq_acked: dict[tuple[str, int], int] = {}
|
|
853
|
+
#: 토픽별 파티션 수(TOPIC_METADATA). 파티션 수는 토픽을 다시 만들 때만 바뀐다.
|
|
854
|
+
self._partitions: dict[str, int] = {}
|
|
855
|
+
#: 키 없는 레코드의 균일 스티키 - 토픽별 [지금 파티션, 그 파티션에 붙인 바이트].
|
|
856
|
+
self._sticky: dict[str, list[int]] = {}
|
|
820
857
|
|
|
821
858
|
self._candidates: list[str] = []
|
|
822
859
|
for s in str(host).split(","):
|
|
@@ -835,7 +872,7 @@ class ILTopicProducer:
|
|
|
835
872
|
self._connect_any()
|
|
836
873
|
|
|
837
874
|
# accumulator - linger/batchSize 로 모았다가 봉투로 내보낸다.
|
|
838
|
-
self._pending: list[
|
|
875
|
+
self._pending: list[_Pending] = []
|
|
839
876
|
self._pending_bytes = 0
|
|
840
877
|
|
|
841
878
|
# ── 접속 / 페일오버 ──────────────────────────────────────────────────
|
|
@@ -876,9 +913,18 @@ class ILTopicProducer:
|
|
|
876
913
|
if self.config.is_idempotence_enabled() and self._pid < 0:
|
|
877
914
|
# 멱등 발행: PID 는 최초 1회만 발급하고 재접속에도 재사용한다
|
|
878
915
|
# (엔진이 레코드 헤더의 Pid/Seq 로 dedup 상태를 복구한다).
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
916
|
+
# arg1 = 이 클라의 멱등 프로토콜 판. 엔진은 응답 arg2 로 자기 판을 준다. "2" 가 아니면
|
|
917
|
+
# seq 범위(토픽 / 파티션)가 달라, 파티션 seq 가 dup-ACK 로 조용히 버려질 수 있다.
|
|
918
|
+
# 그래서 폴백 없이 생성을 실패시킨다.
|
|
919
|
+
res = self._request(ILC.MIMQ_INIT_PRODUCER_ID, _IDEM_PROTOCOL)
|
|
920
|
+
if res is None or res.getCode() != ILC.MIMQ_SUCCESSFUL_TOPIC_OPERATION:
|
|
921
|
+
raise ILOperationException(
|
|
922
|
+
(res.getArg2() if res is not None else "")
|
|
923
|
+
or "MIMQE_INVALID_MSG_FORMAT (init producer id)")
|
|
924
|
+
if (res.getArg2() or "").strip() != _IDEM_PROTOCOL:
|
|
925
|
+
raise ILOperationException(
|
|
926
|
+
"MIMQE_NOT_SUPPORTED (partition-scoped idempotence requires newer engine)")
|
|
927
|
+
self._pid = int(res.getArg1())
|
|
882
928
|
self._fetch_cluster_info()
|
|
883
929
|
self.host = host
|
|
884
930
|
self.port = port
|
|
@@ -921,18 +967,101 @@ class ILTopicProducer:
|
|
|
921
967
|
return res.getReportMsg()
|
|
922
968
|
return None
|
|
923
969
|
|
|
970
|
+
# ── 메타데이터 / 파티션 결정 ────────────────────────────────────────────
|
|
971
|
+
def _partition_count(self, topic: str, refresh: bool = False) -> int:
|
|
972
|
+
"""토픽의 파티션 수. 처음 한 번 TOPIC_METADATA 로 묻고 캐시한다.
|
|
973
|
+
|
|
974
|
+
flush 창의 ``MIMQE_TOPIC_FLUSHED`` 는 ``retryBackoffMs`` 간격으로 ``maxBlockMs`` 까지
|
|
975
|
+
다시 묻는다(flush 는 파티션 수를 바꾸지 않는다). 연결 실패는 발행과 같은 방식으로
|
|
976
|
+
재접속한다(``retries`` 횟수, ``maxBlockMs`` 시한).
|
|
977
|
+
|
|
978
|
+
Raises:
|
|
979
|
+
ILOperationException: 토픽이 없거나 서버가 거절했을 때.
|
|
980
|
+
"""
|
|
981
|
+
if not refresh:
|
|
982
|
+
n = self._partitions.get(topic)
|
|
983
|
+
if n is not None:
|
|
984
|
+
return n
|
|
985
|
+
deadline = time.time() + self.config.get_max_block_ms() / 1000.0
|
|
986
|
+
backoff = max(self.config.get_retry_backoff_ms() / 1000.0, 0.01)
|
|
987
|
+
attempt = 0
|
|
988
|
+
while True:
|
|
989
|
+
try:
|
|
990
|
+
rep = self._request(ILC.MIMQ_TOPIC_METADATA, topic)
|
|
991
|
+
except ILOperationException:
|
|
992
|
+
raise
|
|
993
|
+
except Exception:
|
|
994
|
+
self._connected = False
|
|
995
|
+
remain = deadline - time.time()
|
|
996
|
+
if attempt >= self.config.get_retries() or self._closed or remain <= 0:
|
|
997
|
+
raise
|
|
998
|
+
attempt += 1
|
|
999
|
+
time.sleep(min(backoff, remain))
|
|
1000
|
+
try:
|
|
1001
|
+
self._connect_any()
|
|
1002
|
+
except Exception:
|
|
1003
|
+
pass # 다음 회차에서 다시 시도(시한이 막는다)
|
|
1004
|
+
continue
|
|
1005
|
+
if rep is None:
|
|
1006
|
+
raise ILOperationException("MIMQE_INVALID_MSG_FORMAT (topic metadata : not a report)")
|
|
1007
|
+
if rep.getCode() == ILC.MIMQ_SUCCESSFUL_TOPIC_OPERATION:
|
|
1008
|
+
n = int(rep.getArg1() or "0")
|
|
1009
|
+
if n < 1:
|
|
1010
|
+
raise ILOperationException(
|
|
1011
|
+
"MIMQE_INVALID_MSG_FORMAT (topic metadata : %d partitions)" % n)
|
|
1012
|
+
self._partitions[topic] = n
|
|
1013
|
+
return n
|
|
1014
|
+
reason = rep.getArg2() or "topic metadata failed (code=%d)" % rep.getCode()
|
|
1015
|
+
if "MIMQE_TOPIC_FLUSHED" in reason and time.time() + backoff < deadline:
|
|
1016
|
+
time.sleep(backoff)
|
|
1017
|
+
continue
|
|
1018
|
+
raise ILOperationException(reason)
|
|
1019
|
+
|
|
1020
|
+
def _choose_partition(self, record: ILProducerRecord) -> int:
|
|
1021
|
+
"""파티션을 정한다: 명시 파티션 > 키 해시(murmur2, UTF-8) > 키 없음 균일 스티키.
|
|
1022
|
+
|
|
1023
|
+
엔진은 파티션을 고르지 않는다(IDEMP) - 명시 파티션이 없는 레코드는 거절한다.
|
|
1024
|
+
관리 API 단건 발행도 같은 :func:`~ilink.partitioner.partition_for_key` 를 쓴다.
|
|
1025
|
+
"""
|
|
1026
|
+
cached = record.topic in self._partitions
|
|
1027
|
+
n = self._partition_count(record.topic)
|
|
1028
|
+
if record.partition >= 0:
|
|
1029
|
+
if record.partition >= n and cached:
|
|
1030
|
+
n = self._partition_count(record.topic, refresh=True) # 다시 만들어져 늘었을 수 있다
|
|
1031
|
+
if record.partition >= n:
|
|
1032
|
+
raise ILOperationException(
|
|
1033
|
+
"MIMQE_INVALID_ARGUMENT (Partition : %d, Available : 0~%d)"
|
|
1034
|
+
% (record.partition, n - 1))
|
|
1035
|
+
return record.partition
|
|
1036
|
+
if record.key:
|
|
1037
|
+
return partition_for_key(record.key, n)
|
|
1038
|
+
st = self._sticky.get(record.topic)
|
|
1039
|
+
if st is None or st[0] >= n:
|
|
1040
|
+
st = [random.randrange(n), 0]
|
|
1041
|
+
self._sticky[record.topic] = st
|
|
1042
|
+
return st[0]
|
|
1043
|
+
|
|
1044
|
+
def _sticky_advance(self, topic: str, nbytes: int) -> None:
|
|
1045
|
+
"""키 없는 레코드를 붙인 만큼 센다. ``batchSize`` 를 채우면 다음 파티션으로 넘긴다(Kafka 3.3+)."""
|
|
1046
|
+
st = self._sticky[topic]
|
|
1047
|
+
st[1] += nbytes
|
|
1048
|
+
if st[1] >= self.config.get_batch_size():
|
|
1049
|
+
st[0] = (st[0] + 1) % self._partitions.get(topic, 1)
|
|
1050
|
+
st[1] = 0
|
|
1051
|
+
|
|
924
1052
|
# ── 프레임 조립 ──────────────────────────────────────────────────────
|
|
925
|
-
def _serialize(self, record: ILProducerRecord,
|
|
926
|
-
"""PUBLISH(2020) 프레임 1개. 조립/직렬화는 :class:`ILTopicFrameMsg` 가 한다.
|
|
1053
|
+
def _serialize(self, record: ILProducerRecord, partition: int, corr_id: str) -> bytes:
|
|
1054
|
+
"""PUBLISH(2020) 프레임 1개. 조립/직렬화는 :class:`ILTopicFrameMsg` 가 한다.
|
|
1055
|
+
|
|
1056
|
+
pid/seq 칸은 비워 둔다 - 멱등이면 봉투를 만들 때 :meth:`ILTopicFrameMsg.patchPidSeq` 로 채운다.
|
|
1057
|
+
"""
|
|
927
1058
|
frame = ILTopicFrameMsg.createPublish(
|
|
928
1059
|
record.topic, record.key, corr_id,
|
|
929
1060
|
encode_property(record.properties), record.value,
|
|
930
1061
|
)
|
|
931
1062
|
if self.config.get_acks() == 0:
|
|
932
1063
|
frame.setAckMode("0")
|
|
933
|
-
frame.setPartition(
|
|
934
|
-
if self._pid >= 0:
|
|
935
|
-
frame.setPidSeq(self._pid, seq)
|
|
1064
|
+
frame.setPartition(partition)
|
|
936
1065
|
return frame.to_byte_array()
|
|
937
1066
|
|
|
938
1067
|
@staticmethod
|
|
@@ -957,6 +1086,8 @@ class ILTopicProducer:
|
|
|
957
1086
|
Raises:
|
|
958
1087
|
ILOperationException: producer 가 이미 닫혔거나, 직렬화된 프레임이
|
|
959
1088
|
``maxRequestSize`` 를 넘거나, 레코드 하나가 ``bufferMemory`` 보다 클 때.
|
|
1089
|
+
토픽의 파티션 수를 알 수 없거나(없는 토픽, 조회 실패) 명시 파티션이 범위
|
|
1090
|
+
밖일 때도 - ``acks=0`` 이어도 조용히 버리지 않는다.
|
|
960
1091
|
ILException: 연결 실패가 ``retries`` / ``deliveryTimeoutMs`` 안에
|
|
961
1092
|
복구되지 않았을 때.
|
|
962
1093
|
|
|
@@ -974,14 +1105,13 @@ class ILTopicProducer:
|
|
|
974
1105
|
raise ILOperationException("MIMQE_NOT_SUPPORTED (producer is closed)")
|
|
975
1106
|
future = ILPublishFuture(self.config.get_delivery_timeout_ms())
|
|
976
1107
|
with self._lock:
|
|
977
|
-
#
|
|
978
|
-
#
|
|
979
|
-
#
|
|
980
|
-
#
|
|
981
|
-
seq
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
frame = self._serialize(record, _corr_id(), seq)
|
|
1108
|
+
# IDEMP: 파티션은 producer 가 정한다(엔진은 명시 파티션이 없는 레코드를 거절한다).
|
|
1109
|
+
# 토픽의 파티션 수는 처음 한 번 묻는다. 파티션을 정하지 못하면(없는 토픽, 조회 실패,
|
|
1110
|
+
# 명시 파티션이 범위 밖) **예외를 던진다.** 실패한 future 로 돌려주면 acks=0 처럼 future 를
|
|
1111
|
+
# 보지 않는 호출자에게는 조용한 유실이 된다(엔진 설계 v0.4.1 4.7).
|
|
1112
|
+
# 멱등 seq 는 여기서가 아니라 봉투를 만들 때 매긴다(_send_batch_locked).
|
|
1113
|
+
partition = self._choose_partition(record)
|
|
1114
|
+
frame = self._serialize(record, partition, _corr_id())
|
|
985
1115
|
|
|
986
1116
|
if len(frame) > self.config.get_max_request_size():
|
|
987
1117
|
raise ILOperationException(
|
|
@@ -1000,8 +1130,10 @@ class ILTopicProducer:
|
|
|
1000
1130
|
% (self._pending_bytes + len(frame), self.config.get_buffer_memory())
|
|
1001
1131
|
)
|
|
1002
1132
|
|
|
1003
|
-
self._pending.append((frame, future, record))
|
|
1133
|
+
self._pending.append(_Pending(frame, future, record, partition))
|
|
1004
1134
|
self._pending_bytes += len(frame)
|
|
1135
|
+
if record.partition < 0 and not record.key:
|
|
1136
|
+
self._sticky_advance(record.topic, len(frame))
|
|
1005
1137
|
# linger 0 이면 즉시, 아니면 배치 한도에 닿을 때 내보낸다.
|
|
1006
1138
|
if self.config.get_linger_ms() <= 0 or self._pending_bytes >= self.config.get_batch_size():
|
|
1007
1139
|
self._flush_locked()
|
|
@@ -1044,7 +1176,17 @@ class ILTopicProducer:
|
|
|
1044
1176
|
self._conn.send_bytes(payload)
|
|
1045
1177
|
if not expect_ack:
|
|
1046
1178
|
return None
|
|
1047
|
-
|
|
1179
|
+
res = self._conn.recv(self.config.get_request_timeout_ms())
|
|
1180
|
+
if res.getType() == ILC.MIMQ_REPORT_MESSAGE:
|
|
1181
|
+
reason = res.getReportMsg().getArg2() or ""
|
|
1182
|
+
if "MIMQE_FOLLOWER_NODE" in reason:
|
|
1183
|
+
# 팔로워에 붙어 있다. 봉투 거절은 헤더 단계라 한 건도 적재되지 않았다(엔진 보장,
|
|
1184
|
+
# 이력 248) - 리더 힌트를 앞세워 다시 붙고 같은 봉투를 다시 보낸다(연결 실패와 같은 예산).
|
|
1185
|
+
hint = _extract_leader_hint(ILOperationException(reason))
|
|
1186
|
+
if hint:
|
|
1187
|
+
self._promote(hint)
|
|
1188
|
+
raise ILSessionException(0, reason)
|
|
1189
|
+
return res
|
|
1048
1190
|
except ILOperationException:
|
|
1049
1191
|
raise # 서버 거절 - 재시도 대상이 아니다
|
|
1050
1192
|
except Exception:
|
|
@@ -1075,36 +1217,63 @@ class ILTopicProducer:
|
|
|
1075
1217
|
# 장애를 숨기지 않는다. 멱등 producer 는 재시도하지 않는다(_send_batch_locked 참고).
|
|
1076
1218
|
deadline = time.time() + self.config.get_delivery_timeout_ms() / 1000.0
|
|
1077
1219
|
attempt = 0
|
|
1078
|
-
|
|
1079
|
-
|
|
1220
|
+
cap = self.config.get_max_request_size()
|
|
1221
|
+
queue = batch
|
|
1222
|
+
while queue:
|
|
1223
|
+
# v0.6.9: 봉투 하나의 레코드 프레임 합이 maxRequestSize 를 넘지 않게 나눠 보낸다 - Kafka
|
|
1224
|
+
# max.request.size 와 같은 뜻. 기본값(1MB)에서는 봉투가 약 4만 건(최소 프레임 26바이트)을
|
|
1225
|
+
# 넘지 못해 엔진의 봉투 건수 상한(10만)에 닿지 않는다.
|
|
1226
|
+
n, size = 0, 0
|
|
1227
|
+
while n < len(queue) and (n == 0 or size + len(queue[n].frame) <= cap):
|
|
1228
|
+
size += len(queue[n].frame)
|
|
1229
|
+
n += 1
|
|
1230
|
+
chunk, queue = queue[:n], queue[n:]
|
|
1231
|
+
try:
|
|
1232
|
+
retry = self._send_batch_locked(chunk)
|
|
1233
|
+
except Exception as exc:
|
|
1234
|
+
err = exc if isinstance(exc, ILOperationException) else ILException(exc)
|
|
1235
|
+
for p in queue: # 아직 안 보낸 봉투의 레코드도 같은 사유로 끝낸다
|
|
1236
|
+
p.future._fail(err)
|
|
1237
|
+
raise
|
|
1080
1238
|
if not retry:
|
|
1081
|
-
|
|
1239
|
+
continue
|
|
1082
1240
|
attempt += 1
|
|
1083
1241
|
backoff = self.config.get_retry_backoff_ms() / 1000.0
|
|
1084
1242
|
if attempt > self.config.get_retries() or time.time() + backoff > deadline:
|
|
1085
|
-
for
|
|
1086
|
-
|
|
1087
|
-
|
|
1243
|
+
for p, err in retry:
|
|
1244
|
+
p.future._fail(err)
|
|
1245
|
+
continue
|
|
1088
1246
|
time.sleep(backoff)
|
|
1089
|
-
|
|
1247
|
+
# 되돌린 건은 원래 순서대로 남은 레코드 앞에 둔다 - 파티션 안 순서를 지킨다
|
|
1248
|
+
queue = [p for p, _ in retry] + queue
|
|
1090
1249
|
|
|
1091
1250
|
def _send_batch_locked(self, batch: list) -> list:
|
|
1092
1251
|
"""봉투 하나를 보내고 결과로 각 레코드를 완결한다.
|
|
1093
1252
|
|
|
1094
1253
|
Args:
|
|
1095
|
-
batch (list):
|
|
1254
|
+
batch (list): :class:`_Pending` 목록(보낼 순서).
|
|
1096
1255
|
|
|
1097
1256
|
Returns:
|
|
1098
|
-
list:
|
|
1099
|
-
|
|
1257
|
+
list: **다시 보낼** ``(_Pending, 예외)`` 목록. 비멱등의 ``MIMQE_TOPIC_FLUSHED`` 와
|
|
1258
|
+
멱등의 순서 오류(재번호해 다시 보낸다)다. 나머지 레코드는 여기서 완결된다.
|
|
1100
1259
|
"""
|
|
1101
1260
|
retry = []
|
|
1102
1261
|
corr = _corr_id()
|
|
1103
|
-
total = sum(len(
|
|
1262
|
+
total = sum(len(p.frame) for p in batch)
|
|
1104
1263
|
ack_mode = "0" if self.config.get_acks() == 0 else "1"
|
|
1105
1264
|
payload = bytearray(self._build_envelope(corr, ack_mode, len(batch), total))
|
|
1106
|
-
|
|
1107
|
-
|
|
1265
|
+
idem = self._pid >= 0
|
|
1266
|
+
for p in batch:
|
|
1267
|
+
start = len(payload)
|
|
1268
|
+
payload += p.frame
|
|
1269
|
+
if idem:
|
|
1270
|
+
# IDEMP: seq 는 봉투를 만들 때 (토픽, 파티션)마다 매긴다. 연결이 끊겨 결과를 모르는
|
|
1271
|
+
# 재전송은 _send_with_retry 가 같은 payload 를 그대로 보내므로 같은 seq 가 된다.
|
|
1272
|
+
if p.seq < 0:
|
|
1273
|
+
tp = (p.record.topic, p.partition)
|
|
1274
|
+
p.seq = self._seq_next.get(tp, 0)
|
|
1275
|
+
self._seq_next[tp] = p.seq + 1
|
|
1276
|
+
ILTopicFrameMsg.patchPidSeq(payload, start, self._pid, p.seq)
|
|
1108
1277
|
payload = bytes(payload)
|
|
1109
1278
|
|
|
1110
1279
|
try:
|
|
@@ -1112,44 +1281,78 @@ class ILTopicProducer:
|
|
|
1112
1281
|
|
|
1113
1282
|
if res is None:
|
|
1114
1283
|
# acks=0 은 무응답이다. 좌표를 알 수 없으므로 -1 로 완결시킨다.
|
|
1115
|
-
for
|
|
1116
|
-
|
|
1284
|
+
for p in batch:
|
|
1285
|
+
p.future._complete(ILRecordMetadata(p.record.topic, p.partition, -1, 0))
|
|
1117
1286
|
return retry
|
|
1118
1287
|
|
|
1119
1288
|
if res.getType() == ILC.MIMQ_REPORT_MESSAGE:
|
|
1289
|
+
# 봉투 전체 거절 - 건별 결과가 없다. 적재 여부를 모르는 것으로 보고 seq 는 쓴 채로 둔다.
|
|
1290
|
+
# 적재되지 않았다면 다음 봉투가 순서 오류(expected n)를 받아 거기서 다시 맞춘다.
|
|
1120
1291
|
err = ILOperationException(res.getReportMsg().getArg2())
|
|
1121
|
-
for
|
|
1122
|
-
|
|
1292
|
+
for p in batch:
|
|
1293
|
+
p.future._fail(err)
|
|
1123
1294
|
return retry
|
|
1124
1295
|
|
|
1125
1296
|
frame = res.getTopicFrameMsg()
|
|
1297
|
+
# 재번호 기준 - (토픽, 파티션) -> [가장 낮은 거절 seq, 순서 오류의 expected 또는 None]
|
|
1298
|
+
rejected: dict[tuple[str, int], list] = {}
|
|
1126
1299
|
# 배치 ACK 은 **건별 결과**를 돌려준다. 일부만 실패할 수 있다.
|
|
1127
|
-
for i,
|
|
1128
|
-
_, fut, rec = item
|
|
1300
|
+
for i, p in enumerate(batch):
|
|
1129
1301
|
if i >= frame.batchCount:
|
|
1130
|
-
|
|
1302
|
+
# 결과 모름 - seq 는 쓴 것으로 둔다(재번호하지 않는다).
|
|
1303
|
+
p.future._fail(ILOperationException("MIMQE_INVALID_MSG_FORMAT (ack count mismatch)"))
|
|
1131
1304
|
continue
|
|
1132
1305
|
code = frame.resultCodes[i]
|
|
1306
|
+
tp = (p.record.topic, p.partition)
|
|
1133
1307
|
if code == ILC.MIMQ_SUCCESSFUL_TOPIC_PUBLISH:
|
|
1134
|
-
|
|
1135
|
-
|
|
1308
|
+
# 중복 응답(dup-ACK)도 성공 코드로 온다. 엔진 Recent 캐시 밖이면 offset 이 -1 이다.
|
|
1309
|
+
if idem and p.seq > self._seq_acked.get(tp, -1):
|
|
1310
|
+
self._seq_acked[tp] = p.seq
|
|
1311
|
+
p.future._complete(ILRecordMetadata(
|
|
1312
|
+
p.record.topic, frame.resultPartitions[i],
|
|
1136
1313
|
frame.resultOffsets[i], frame.resultTimestamps[i]))
|
|
1137
1314
|
continue
|
|
1138
1315
|
reason = frame.resultReasons[i] or "publish failed"
|
|
1139
1316
|
err = ILOperationException("%s (code=%d)" % (reason, code))
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1317
|
+
if "MIMQE_OBJECT_NOT_FOUND" in reason or "MIMQE_INVALID_ARGUMENT (Partition" in reason:
|
|
1318
|
+
self._partitions.pop(p.record.topic, None) # 다시 만들어졌을 수 있다 - 다음 send 가 다시 묻는다
|
|
1319
|
+
ooo = None
|
|
1320
|
+
if idem:
|
|
1321
|
+
# 건별 NACK = 적재 안 됨(엔진 계약 4.3.1). 이 seq 는 쓰이지 않았으니 돌려받는다.
|
|
1322
|
+
if code == ILC.MIMQ_OUT_OF_ORDER_SEQUENCE or "OUT_OF_ORDER" in reason:
|
|
1323
|
+
m = _OOO_EXPECTED.search(reason)
|
|
1324
|
+
ooo = int(m.group(1)) if m else -1
|
|
1325
|
+
r = rejected.setdefault(tp, [p.seq, None])
|
|
1326
|
+
r[0] = min(r[0], p.seq)
|
|
1327
|
+
if ooo is not None and ooo >= 0:
|
|
1328
|
+
r[1] = ooo
|
|
1329
|
+
p.seq = -1
|
|
1330
|
+
# 다시 보내는 것 두 가지.
|
|
1331
|
+
# ① 비멱등 FLUSHED(TFLUSH-3) - 호출자에게 알리지 않고 그 레코드만 다시 보낸다. 멱등은
|
|
1332
|
+
# 올린다: flush 로 새로 만든 파티션은 처음 받은 seq 를 기준선으로 삼아, 되돌린 건이
|
|
1333
|
+
# 뒤 레코드보다 늦게 들어가면 순서가 바뀐다(엔진 설계 4.8, 계약 유지).
|
|
1334
|
+
# ② 멱등 순서 오류 - 그 레코드는 적재되지 않았고, 엔진이 기다리는 번호(expected)를
|
|
1335
|
+
# 알려 준다. 그 번호부터 다시 매겨 보낸다. 앞 레코드가 거절돼 생긴 구멍과, 결과를
|
|
1336
|
+
# 모른 채 실패한 봉투가 실제로는 적재되지 않아 생긴 구멍을 둘 다 메운다.
|
|
1337
|
+
if (("MIMQE_TOPIC_FLUSHED" in reason and not idem)
|
|
1338
|
+
or (ooo is not None and ooo >= 0)):
|
|
1339
|
+
retry.append((p, err))
|
|
1146
1340
|
else:
|
|
1147
|
-
|
|
1341
|
+
p.future._fail(err)
|
|
1342
|
+
# 재번호(엔진 설계 5.5). 다음 seq = max(기준, 받아들여진 가장 큰 seq + 1).
|
|
1343
|
+
# 기준은 순서 오류가 있으면 그 expected(엔진의 last + 1), 없으면 가장 낮은 거절 seq 다.
|
|
1344
|
+
# 받아들여진 seq 이하는 절대 다시 쓰지 않는다 - 상태 없는 파티션은 처음 본 seq 를
|
|
1345
|
+
# 기준선으로 받으므로, 앞 건이 거절되고 뒤 건이 기준선이 된 뒤 번호를 되돌리면 그 번호는
|
|
1346
|
+
# 중복 응답(적재 없음)으로 조용히 사라진다. 봉투는 한 번에 하나만 나가므로(동기) 이
|
|
1347
|
+
# 파티션의 앞선 결과는 여기서 모두 알고 있다(재번호 규칙 i).
|
|
1348
|
+
for tp, (low, expected) in rejected.items():
|
|
1349
|
+
base = expected if expected is not None else low
|
|
1350
|
+
self._seq_next[tp] = max(base, self._seq_acked.get(tp, -1) + 1)
|
|
1148
1351
|
return retry
|
|
1149
1352
|
except Exception as exc:
|
|
1150
1353
|
err = exc if isinstance(exc, ILOperationException) else ILException(exc)
|
|
1151
|
-
for
|
|
1152
|
-
|
|
1354
|
+
for p in batch:
|
|
1355
|
+
p.future._fail(err)
|
|
1153
1356
|
raise
|
|
1154
1357
|
|
|
1155
1358
|
def close(self, timeout_ms: int = 5000) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jetstream-api
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.9
|
|
4
4
|
Summary: Python client API for iLink M.O.M. (Message Oriented Middleware)
|
|
5
5
|
Author: snowjeans
|
|
6
6
|
License: Boost Software License - Version 1.0 - August 17th, 2003
|
|
@@ -54,6 +54,18 @@ Python으로 작성된 ILink 클라이언트 API입니다.
|
|
|
54
54
|
## 설치 방법
|
|
55
55
|
pip install jetstream-api
|
|
56
56
|
|
|
57
|
+
## 0.6.9 변경 - 파티션을 클라이언트가 정한다 (엔진 7.0.1.3401 이상 필요)
|
|
58
|
+
|
|
59
|
+
엔진이 더는 파티션을 고르지 않습니다. 이 판부터 producer 와 관리 API 단건 발행이 파티션을 정해 보냅니다(Kafka 와 같은 방식).
|
|
60
|
+
|
|
61
|
+
- **키가 있으면** Kafka 와 같은 murmur2 로 **UTF-8 키 바이트**를 해시해 정합니다. 같은 키는 파이썬·자바 어느 클라이언트든 같은 파티션으로 갑니다.
|
|
62
|
+
- **키가 없으면** producer 는 한 파티션에 `batchSize` 바이트씩 붙였다가 다음 파티션으로 넘깁니다(Kafka 3.3+ 균일 스티키). 관리 API 단건 발행은 토픽별 라운드로빈입니다.
|
|
63
|
+
- 토픽의 파티션 수는 처음 보낼 때 한 번 묻습니다. **알 수 없으면(없는 토픽 등) `send()` 가 예외를 던집니다.** `acks=0` 도 같습니다.
|
|
64
|
+
- 멱등 발행(`enableIdempotence(True)`)은 (토픽, 파티션)마다 번호를 매깁니다. 한 레코드가 거절돼도(너무 큼 등) 뒤 레코드는 계속 적재되고, 여러 건을 한 봉투에 묶어 보냅니다.
|
|
65
|
+
- `maxRequestSize`(기본 1MB)는 레코드 한 건과 **봉투 하나의 크기**를 함께 묶습니다.
|
|
66
|
+
|
|
67
|
+
★ 그 이전 엔진에서는 발행이 모두 실패합니다(파티션 수를 물을 수 없다). 그런 엔진에는 0.6.8 을 쓰십시오.
|
|
68
|
+
|
|
57
69
|
## 0.2.0 신규 - pub/sub 토픽
|
|
58
70
|
|
|
59
71
|
큐에 더해 **pub/sub 토픽**을 지원합니다. 발행된 메시지는 retention 정책이 지울 때까지 보존되어
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|