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.
Files changed (26) hide show
  1. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/PKG-INFO +13 -1
  2. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/README.md +12 -0
  3. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/__init__.py +1 -1
  4. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/admin.py +84 -5
  5. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/admintopic.py +11 -4
  6. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/common.py +4 -0
  7. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/msg.py +27 -0
  8. jetstream_api-0.6.9/ilink/partitioner.py +78 -0
  9. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/producer.py +263 -60
  10. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/PKG-INFO +13 -1
  11. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/SOURCES.txt +1 -0
  12. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/pyproject.toml +1 -1
  13. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/LICENSE.txt +0 -0
  14. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/connectivity.py +0 -0
  15. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/exception.py +0 -0
  16. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/pattern.py +0 -0
  17. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/properties.py +0 -0
  18. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/py.typed +0 -0
  19. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/qmgr.py +0 -0
  20. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/topic.py +0 -0
  21. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/ilink/util.py +0 -0
  22. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/dependency_links.txt +0 -0
  23. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/requires.txt +0 -0
  24. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/jetstream_api.egg-info/top_level.txt +0 -0
  25. {jetstream_api-0.6.8 → jetstream_api-0.6.9}/setup.cfg +0 -0
  26. {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.8
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,6 @@
1
1
  """iLink - Python client API for iLink M.O.M. (Message Oriented Middleware)."""
2
2
 
3
- __version__ = "0.6.8"
3
+ __version__ = "0.6.9"
4
4
 
5
5
  # --- Admin / Service ---
6
6
  from .admin import ILAdminService, ILAdminQmgr, ILLogBrowser, ILQueueBrowser
@@ -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
- partition (int | None): 파티션 직접 지정. None(기본)이면 서버가 정한다.
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
- options = "" if partition is None else "partition=%d" % partition
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
- raise ILOperationException(report.getArg2())
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건의 **프레임** 크기 상한(바이트). (기본 1048576 = 1MB)
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 - **토픽별**로 0 부터 센다(자바 seqByTopic 과 같다). 엔진은 (pid, 토픽)마다
818
- #: seq 가 이어지는지 본다.
819
- self._seq_by_topic: dict[str, int] = {}
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[tuple[bytes, ILPublishFuture, ILProducerRecord]] = []
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
- res = self._request(ILC.MIMQ_INIT_PRODUCER_ID)
880
- if res is not None and res.getArg1():
881
- self._pid = int(res.getArg1())
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, corr_id: str, seq: int) -> bytes:
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(record.partition)
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
- # v0.6.8 멱등 seq 는 (pid, 토픽)마다 0 부터 이어져야 한다 - 엔진 CheckProducerSeq 가
978
- # 토픽마다 LastSeq + 1 을 요구한다. 전에는 producer 전체에 카운터가 하나라, 한
979
- # producer 로 토픽을 섞어 쓰면 두 번째 토픽부터 OUT_OF_ORDER 가 났다(2026-09-11 실측,
980
- # 봉투당 1건에서도). 재전송은 레코드가 받은 seq 를 그대로 쓴다(프레임째 다시 보낸다).
981
- seq = self._seq_by_topic.get(record.topic, 0)
982
- if self._pid >= 0:
983
- self._seq_by_topic[record.topic] = seq + 1
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
- return self._conn.recv(self.config.get_request_timeout_ms())
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
- while True:
1079
- retry = self._send_batch_locked(batch)
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
- return
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 (_, fut, _), err in retry:
1086
- fut._fail(err)
1087
- return
1243
+ for p, err in retry:
1244
+ p.future._fail(err)
1245
+ continue
1088
1246
  time.sleep(backoff)
1089
- batch = [item for item, _ in retry]
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): ``(프레임, future, 레코드)`` 목록.
1254
+ batch (list): :class:`_Pending` 목록(보낼 순서).
1096
1255
 
1097
1256
  Returns:
1098
- list: ``MIMQE_TOPIC_FLUSHED`` 로 실패해 **다시 보낼** ``((프레임, future, 레코드), 예외)``
1099
- 목록. 나머지 레코드는 여기서 완결된다.
1257
+ list: **다시 보낼** ``(_Pending, 예외)`` 목록. 비멱등의 ``MIMQE_TOPIC_FLUSHED`` 와
1258
+ 멱등의 순서 오류(재번호해 다시 보낸다)다. 나머지 레코드는 여기서 완결된다.
1100
1259
  """
1101
1260
  retry = []
1102
1261
  corr = _corr_id()
1103
- total = sum(len(f) for f, _, _ in batch)
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
- for frame, _, _ in batch:
1107
- payload += frame
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 _, fut, rec in batch:
1116
- fut._complete(ILRecordMetadata(rec.topic, rec.partition, -1, 0))
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 _, fut, _ in batch:
1122
- fut._fail(err)
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, item in enumerate(batch):
1128
- _, fut, rec = item
1300
+ for i, p in enumerate(batch):
1129
1301
  if i >= frame.batchCount:
1130
- fut._fail(ILOperationException("MIMQE_INVALID_MSG_FORMAT (ack count mismatch)"))
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
- fut._complete(ILRecordMetadata(
1135
- rec.topic, frame.resultPartitions[i],
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
- # TFLUSH-3: FLUSHED 건은 호출자에게 알리지 않고 다시 보낸다. 단 멱등이면 안 된다 -
1141
- # flush 로 새로 만든 토픽은 처음 받은 seq 를 기준선으로 삼고 그 이하는 적재 없이
1142
- # 성공(dup-ACK)으로 답한다(엔진 TopicStorage::CheckProducerSeq). 뒤 레코드가 먼저
1143
- # 들어간 뒤 되돌린 건을 보내면 성공 응답에 레코드는 없는 조용한 유실이 된다.
1144
- if "MIMQE_TOPIC_FLUSHED" in reason and not self.config.is_idempotence_enabled():
1145
- retry.append((item, err))
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
- fut._fail(err)
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 _, fut, _ in batch:
1152
- fut._fail(err)
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.8
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,7 @@ ilink/common.py
8
8
  ilink/connectivity.py
9
9
  ilink/exception.py
10
10
  ilink/msg.py
11
+ ilink/partitioner.py
11
12
  ilink/pattern.py
12
13
  ilink/producer.py
13
14
  ilink/properties.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jetstream-api"
7
- version = "0.6.8"
7
+ version = "0.6.9"
8
8
  description = "Python client API for iLink M.O.M. (Message Oriented Middleware)"
9
9
  authors = [{name = "snowjeans"}]
10
10
  readme = "README.md"
File without changes
File without changes