jetstream-api 0.7.0__tar.gz → 0.7.2__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.7.0 → jetstream_api-0.7.2}/PKG-INFO +25 -2
  2. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/README.md +24 -1
  3. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/__init__.py +1 -1
  4. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/producer.py +252 -116
  5. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/qmgr.py +6 -3
  6. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/jetstream_api.egg-info/PKG-INFO +25 -2
  7. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/pyproject.toml +1 -1
  8. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/LICENSE.txt +0 -0
  9. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/admin.py +0 -0
  10. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/admintopic.py +0 -0
  11. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/common.py +0 -0
  12. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/connectivity.py +0 -0
  13. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/exception.py +0 -0
  14. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/msg.py +0 -0
  15. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/partitioner.py +0 -0
  16. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/pattern.py +0 -0
  17. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/properties.py +0 -0
  18. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/py.typed +0 -0
  19. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/topic.py +0 -0
  20. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/ilink/util.py +0 -0
  21. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/jetstream_api.egg-info/SOURCES.txt +0 -0
  22. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/jetstream_api.egg-info/dependency_links.txt +0 -0
  23. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/jetstream_api.egg-info/requires.txt +0 -0
  24. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/jetstream_api.egg-info/top_level.txt +0 -0
  25. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/setup.cfg +0 -0
  26. {jetstream_api-0.7.0 → jetstream_api-0.7.2}/tests/test_example.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jetstream-api
3
- Version: 0.7.0
3
+ Version: 0.7.2
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,28 @@ Python으로 작성된 ILink 클라이언트 API입니다.
54
54
  ## 설치 방법
55
55
  pip install jetstream-api
56
56
 
57
+ ## 0.7.2 변경 - batchSize 기본값 262144 로 되돌림, 발행 경로 CPU 절감 (자바 v2.3.2 와 같음)
58
+
59
+ - `batchSize` 기본값을 **262144** 로 되돌렸습니다. 0.7.1 의 16384 에서 파이썬 클라는 처리량이 24~35% 낮았습니다(GIL 이
60
+ 천장이라 배치마다 드는 비용이 그대로 처리량에서 빠집니다). 멱등 기본 켜짐은 그대로입니다.
61
+ - 발행 경로 CPU 를 줄였습니다(API 는 그대로입니다). 레코드 future 가 배치 결과 하나를 함께 가리키고(완결은 배치당 한 번),
62
+ 키 -> 파티션 결과를 캐시하고, `send()` 가 설정값을 레코드마다 다시 읽지 않고, I/O 스레드를 깨우는 신호를 합치고,
63
+ ACK 하나의 배치 완결을 잠금 한 번에 묶습니다.
64
+ - producer 는 만들 때 설정 객체를 **복사**합니다(자바 v2.3.2 도 같고, Kafka 와 같습니다). 만든 뒤 넘긴 객체를 바꿔도 그
65
+ producer 에는 반영되지 않습니다 - 설정을 바꾸려면 producer 를 새로 만드십시오.
66
+ - 문서: 멱등이 거르는 것은 producer 안의 재전송뿐입니다 - 앱이 `send` 를 다시 부르면 새 레코드로 적재됩니다.
67
+
68
+ ## 0.7.1 변경 - producer 기본값: 멱등 켜짐, batchSize 16384 (자바 v2.3.1 과 같음)
69
+
70
+ - `enableIdempotence` 기본값이 **켜짐**입니다. 멱등을 직접 정하지 않았으면 `acks(0)` 이나 `maxInFlight` 가 5 를 넘을 때
71
+ 멱등이 저절로 꺼집니다. `enableIdempotence(True)` 를 명시하고 그 둘과 함께 쓰면 전처럼 생성 때 `ValueError` 입니다.
72
+ - 멱등이면 토픽을 비우거나(FLUSH) epoch 가 바뀐 순간 걸려 있던 배치를 다시 보내지 않고 실패로 올립니다
73
+ (`MIMQE_TOPIC_FLUSHED` / `MIMQE_TOPIC_EPOCH_MISMATCH`). 0.7.0 기본처럼 조용히 다시 보내려면 `enableIdempotence(False)` 를
74
+ 명시하십시오.
75
+ - `batchSize` 기본값이 **16384** 입니다(파티션 배치 하나의 상한). 0.7.0 은 262144 였습니다 - 처리량이 모자라면
76
+ `batchSize(262144)` 처럼 늘리십시오.
77
+ - producer 수 안내: 한 토픽의 producer(연결)는 1~4개면 충분합니다(엔진 7.0.1.3407 실측).
78
+
57
79
  ## 0.7.0 변경 - 발행 v2: producer 하나 = 토픽 하나, 파티션별 배치, 봉투 겹쳐 보내기 (엔진 v2 필요)
58
80
 
59
81
  발행 경로를 엔진의 새 발행 형식(v2)으로 바꿨습니다. **엔진도 v2 판이어야 합니다** - 옛 엔진에는 발행이 되지 않고
@@ -61,7 +83,8 @@ pip install jetstream-api
61
83
 
62
84
  - **producer 하나 = 토픽 하나 = 연결 하나.** `qmgr.create_producer("ORDER.EVENT", config)` 로 만듭니다. 생성할 때 토픽의
63
85
  파티션 수를 받아 두므로 없는 토픽이면 생성 단계에서 `MIMQE_OBJECT_NOT_FOUND` 로 실패합니다. 토픽이 여러 개면 토픽마다
64
- producer 를 만들고, 연결이 더 필요하면 producer 를 더 만듭니다(예: 발행 스레드마다 하나).
86
+ producer 를 만듭니다. 한 토픽의 producer(연결)는 1~4개면 충분합니다 - 엔진 7.0.1.3407 실측에서 8개로 늘리면 어느 크기든
87
+ 오히려 줄었습니다. 큰 레코드(1KB 급)는 1개로도 디스크가 먼저 한계에 닿고, 작은 레코드(100B 급)는 4개 근처가 최대입니다.
65
88
  - 레코드 토픽이 producer 토픽과 다르면 `send()` 가 `MIMQE_INVALID_ARGUMENT` 로 거부합니다.
66
89
  - 옛 `create_producer(config)` / `ILTopicProducer(host, port, name, config)` 모양으로 부르면 `TypeError` 가 납니다.
67
90
  - 배치는 **파티션마다** 모입니다. `batchSize`(기본 **262144**, 자바와 같음)는 파티션 배치 하나의 상한입니다. 보낼 때는
@@ -8,6 +8,28 @@ Python으로 작성된 ILink 클라이언트 API입니다.
8
8
  ## 설치 방법
9
9
  pip install jetstream-api
10
10
 
11
+ ## 0.7.2 변경 - batchSize 기본값 262144 로 되돌림, 발행 경로 CPU 절감 (자바 v2.3.2 와 같음)
12
+
13
+ - `batchSize` 기본값을 **262144** 로 되돌렸습니다. 0.7.1 의 16384 에서 파이썬 클라는 처리량이 24~35% 낮았습니다(GIL 이
14
+ 천장이라 배치마다 드는 비용이 그대로 처리량에서 빠집니다). 멱등 기본 켜짐은 그대로입니다.
15
+ - 발행 경로 CPU 를 줄였습니다(API 는 그대로입니다). 레코드 future 가 배치 결과 하나를 함께 가리키고(완결은 배치당 한 번),
16
+ 키 -> 파티션 결과를 캐시하고, `send()` 가 설정값을 레코드마다 다시 읽지 않고, I/O 스레드를 깨우는 신호를 합치고,
17
+ ACK 하나의 배치 완결을 잠금 한 번에 묶습니다.
18
+ - producer 는 만들 때 설정 객체를 **복사**합니다(자바 v2.3.2 도 같고, Kafka 와 같습니다). 만든 뒤 넘긴 객체를 바꿔도 그
19
+ producer 에는 반영되지 않습니다 - 설정을 바꾸려면 producer 를 새로 만드십시오.
20
+ - 문서: 멱등이 거르는 것은 producer 안의 재전송뿐입니다 - 앱이 `send` 를 다시 부르면 새 레코드로 적재됩니다.
21
+
22
+ ## 0.7.1 변경 - producer 기본값: 멱등 켜짐, batchSize 16384 (자바 v2.3.1 과 같음)
23
+
24
+ - `enableIdempotence` 기본값이 **켜짐**입니다. 멱등을 직접 정하지 않았으면 `acks(0)` 이나 `maxInFlight` 가 5 를 넘을 때
25
+ 멱등이 저절로 꺼집니다. `enableIdempotence(True)` 를 명시하고 그 둘과 함께 쓰면 전처럼 생성 때 `ValueError` 입니다.
26
+ - 멱등이면 토픽을 비우거나(FLUSH) epoch 가 바뀐 순간 걸려 있던 배치를 다시 보내지 않고 실패로 올립니다
27
+ (`MIMQE_TOPIC_FLUSHED` / `MIMQE_TOPIC_EPOCH_MISMATCH`). 0.7.0 기본처럼 조용히 다시 보내려면 `enableIdempotence(False)` 를
28
+ 명시하십시오.
29
+ - `batchSize` 기본값이 **16384** 입니다(파티션 배치 하나의 상한). 0.7.0 은 262144 였습니다 - 처리량이 모자라면
30
+ `batchSize(262144)` 처럼 늘리십시오.
31
+ - producer 수 안내: 한 토픽의 producer(연결)는 1~4개면 충분합니다(엔진 7.0.1.3407 실측).
32
+
11
33
  ## 0.7.0 변경 - 발행 v2: producer 하나 = 토픽 하나, 파티션별 배치, 봉투 겹쳐 보내기 (엔진 v2 필요)
12
34
 
13
35
  발행 경로를 엔진의 새 발행 형식(v2)으로 바꿨습니다. **엔진도 v2 판이어야 합니다** - 옛 엔진에는 발행이 되지 않고
@@ -15,7 +37,8 @@ pip install jetstream-api
15
37
 
16
38
  - **producer 하나 = 토픽 하나 = 연결 하나.** `qmgr.create_producer("ORDER.EVENT", config)` 로 만듭니다. 생성할 때 토픽의
17
39
  파티션 수를 받아 두므로 없는 토픽이면 생성 단계에서 `MIMQE_OBJECT_NOT_FOUND` 로 실패합니다. 토픽이 여러 개면 토픽마다
18
- producer 를 만들고, 연결이 더 필요하면 producer 를 더 만듭니다(예: 발행 스레드마다 하나).
40
+ producer 를 만듭니다. 한 토픽의 producer(연결)는 1~4개면 충분합니다 - 엔진 7.0.1.3407 실측에서 8개로 늘리면 어느 크기든
41
+ 오히려 줄었습니다. 큰 레코드(1KB 급)는 1개로도 디스크가 먼저 한계에 닿고, 작은 레코드(100B 급)는 4개 근처가 최대입니다.
19
42
  - 레코드 토픽이 producer 토픽과 다르면 `send()` 가 `MIMQE_INVALID_ARGUMENT` 로 거부합니다.
20
43
  - 옛 `create_producer(config)` / `ILTopicProducer(host, port, name, config)` 모양으로 부르면 `TypeError` 가 납니다.
21
44
  - 배치는 **파티션마다** 모입니다. `batchSize`(기본 **262144**, 자바와 같음)는 파티션 배치 하나의 상한입니다. 보낼 때는
@@ -1,6 +1,6 @@
1
1
  """iLink - Python client API for iLink M.O.M. (Message Oriented Middleware)."""
2
2
 
3
- __version__ = "0.7.0"
3
+ __version__ = "0.7.2"
4
4
 
5
5
  # --- Admin / Service ---
6
6
  from .admin import ILAdminService, ILAdminQmgr, ILLogBrowser, ILQueueBrowser
@@ -12,6 +12,7 @@ producer 하나 = 토픽 하나 = 전용 연결 하나 + I/O 스레드 하나다
12
12
  from __future__ import annotations
13
13
 
14
14
  import collections
15
+ import copy
15
16
  import itertools
16
17
  import os
17
18
  import random
@@ -31,6 +32,10 @@ from .partitioner import partition_for_key
31
32
 
32
33
  #: retries 기본값. 사실상 무제한 재시도를 뜻한다.
33
34
  _INT_MAX = 2147483647
35
+ #: 키 -> 파티션 캐시에 둘 키 수 상한(producer 당). 차면 비우고 다시 채운다
36
+ _KEY_CACHE_MAX = 16384
37
+ #: 키 -> 파티션 캐시에 담을 키 길이 상한(바이트). 긴 키는 매번 해시한다(캐시 메모리를 묶어 둔다)
38
+ _KEY_CACHE_MAX_KEY = 256
34
39
 
35
40
 
36
41
  class ILProducerConfig:
@@ -58,7 +63,8 @@ class ILProducerConfig:
58
63
  self._acks = 1
59
64
  #: 배치를 모으는 시간(ms). 0 이면 모이는 대로 즉시 보낸다
60
65
  self._lingerMs = 0
61
- #: 파티션 배치 하나의 크기 상한(바이트). 0.7.0 부터 자바와 같은 262144(사용자 결정)
66
+ # 0.7.2: 262144 로 되돌렸다(사용자 결정 2026-09-13). 0.7.1 의 16384 에서 24~35% 느렸다(GIL 천장)
67
+ #: 파티션 배치 하나의 크기 상한(바이트). 기본 262144
62
68
  self._batchSize = 262144
63
69
  #: 동시에 ACK 를 기다릴 봉투 수 상한
64
70
  self._maxInFlight = 5
@@ -78,14 +84,20 @@ class ILProducerConfig:
78
84
  self._maxRequestSize = 1048576
79
85
  #: 접속한 큐관리자가 받는 프레임 길이 상한(바이트) - 엔진 Runtime@MaxFrameLength 기본 128MiB
80
86
  self._maxFrameLength = ILC.MIMQ_DEFAULT_MAX_FRAME_LENGTH
81
- #: 멱등 발행(PID 발급 + 시퀀스). 재전송 시 중복 적재를 막는다
82
- self._enableIdempotence = False
87
+ # 0.7.1: Kafka 3.x 와 같은 기본 켜짐(사용자 결정 2026-09-13)
88
+ #: 멱등 발행(PID 발급 + 시퀀스). 재전송 시 중복 적재를 막는다. 기본 켜짐
89
+ self._enableIdempotence = True
90
+ #: 멱등을 사용자가 정했는지. 정하지 않았으면 acks=0 이나 maxInFlight > 5 에서 저절로 꺼진다
91
+ self._idempotenceSet = False
83
92
 
84
93
  def __setattr__(self, name: str, value) -> None:
85
94
  # 구버전 호환: cfg.acks = 0 / cfg.lingerMs = 5 같은 직접 대입을 내부 슬롯으로 돌린다.
86
95
  # (예전 이름 idempotent 도 함께 받아 준다)
87
96
  if name == "idempotent":
88
97
  name = "enableIdempotence"
98
+ if name == "enableIdempotence":
99
+ # 직접 대입도 사용자가 정한 것이다 - 기본 멱등의 저절로 끄기를 적용하지 않는다
100
+ object.__setattr__(self, "_idempotenceSet", True)
89
101
  if name in self._OPTIONS:
90
102
  name = "_" + name
91
103
  object.__setattr__(self, name, value)
@@ -104,7 +116,8 @@ class ILProducerConfig:
104
116
  ILProducerConfig: 체이닝을 위해 자기 자신.
105
117
 
106
118
  Note:
107
- ``acks=0`` 은 멱등 발행과 함께 쓸 수 없다 (:meth:`validate` 가 거부).
119
+ 멱등(기본 켜짐)을 정하지 않았으면 ``acks=0`` 은 멱등을 끈다. ``enableIdempotence(True)`` 를
120
+ 명시하고 ``acks=0`` 이면 :meth:`validate` 가 거부한다.
108
121
  """
109
122
  self._acks = 0 if n == 0 else 1; return self
110
123
 
@@ -137,7 +150,8 @@ class ILProducerConfig:
137
150
  ILProducerConfig: 체이닝을 위해 자기 자신.
138
151
 
139
152
  Note:
140
- 뜻과 기본값이 Java 클라이언트와 같다(0.7.0).
153
+ 뜻과 기본값이 Java 클라이언트와 같다(0.7.2 에서 262144 로 되돌렸다. 0.7.1 은 16384 였고 파이썬은
154
+ 그 값에서 24~35% 느렸다).
141
155
  ``lingerMs`` 가 0 이면 자르는 크기로만 쓰이고(배치는 보낼 수 있게 되는 즉시 나간다),
142
156
  ``lingerMs`` 가 0 보다 크면 전송 조건도 된다(이만큼 차거나 lingerMs 가 지나면 보낸다).
143
157
  레코드 하나가 이 값보다 크면 그 레코드 혼자 배치 하나가 된다. 보낼 때는 준비된
@@ -156,9 +170,9 @@ class ILProducerConfig:
156
170
 
157
171
  Note:
158
172
  I/O 스레드가 봉투를 이만큼 겹쳐 보낸다(0.7.0). 엔진은 한 연결의 봉투를 겹쳐
159
- 처리하고 응답은 요청 순서대로 온다. **enableIdempotence 를 켜면 5 이하여야 한다** -
160
- 엔진이 파티션마다 최근 배치 5개로 중복을 가리기 때문이다(넘으면 :meth:`validate` 가
161
- ``ValueError``).
173
+ 처리하고 응답은 요청 순서대로 온다. **멱등(기본 켜짐)은 5 이하에서만 쓰인다** -
174
+ 엔진이 파티션마다 최근 배치 5개로 중복을 가리기 때문이다. 멱등을 정하지 않았으면 5 를 넘길 때
175
+ 멱등이 꺼지고, ``enableIdempotence(True)`` 를 명시했으면 :meth:`validate` 가 ``ValueError``.
162
176
  """
163
177
  self._maxInFlight = max(1, n); return self
164
178
 
@@ -186,8 +200,10 @@ class ILProducerConfig:
186
200
  보낸다(순서 유지). 재시도 총 시간은 :meth:`deliveryTimeoutMs` 로도 함께 제한된다.
187
201
 
188
202
  Warning:
189
- 재시도는 **중복 적재를 만들 수 있다.** ACK 를 못 받은 것과 서버가 적재를
190
- 못 한 것은 구분되지 않는다. 중복이 곤란하면 :meth:`enableIdempotence`.
203
+ 비멱등(``enableIdempotence(False)``)이면 재시도는 **중복 적재를 만들 수 있다.** ACK 를
204
+ 못 받은 것과 서버가 적재를 못 한 것은 구분되지 않는다. 멱등(기본 켜짐)이면 producer 안의
205
+ 재시도는 엔진이 걸러 한 번만 적재된다. 앱이 ``send`` 를 다시 부르는 재발행은 새 레코드라
206
+ 멱등이어도 걸러지지 않는다.
191
207
  """
192
208
  self._retries = max(0, n); return self
193
209
 
@@ -310,23 +326,34 @@ class ILProducerConfig:
310
326
  return self
311
327
 
312
328
  def enableIdempotence(self, b: bool) -> "ILProducerConfig":
313
- """멱등 발행(중복 제거)을 켠다. (기본 False)
329
+ """멱등 발행(중복 제거)을 켜거나 끈다. (기본 켜짐 - 0.7.1)
314
330
 
315
331
  켜면 접속 시 서버에서 PID 를 발급받아 파티션 배치마다 (PID, baseSeq) 를 실어 보낸다.
316
- 재시도로 같은 배치가 두 번 도착해도 서버가 시퀀스로 걸러 **한 번만 적재한다.**
332
+ 재시도로 같은 배치가 두 번 도착해도 서버가 시퀀스로 걸러 **한 번만 적재한다.** 걸러지는 것은
333
+ producer 안의 재시도뿐이다 - 앱이 ``send`` 를 다시 부르면 새 레코드로 적재된다.
317
334
  ``maxInFlight`` 는 5 이하여야 한다.
318
335
 
336
+ 이 메서드로 정하지 않으면 ``acks=0`` 이거나 ``maxInFlight`` 가 5 를 넘을 때 멱등이 저절로 꺼진다.
337
+ 명시적으로 켜고 그 둘과 함께 쓰면 :meth:`validate` 가 거부한다.
338
+
339
+ 멱등이면 토픽 FLUSH / epoch 변경으로 거절된 배치(``MIMQE_TOPIC_FLUSHED`` /
340
+ ``MIMQE_TOPIC_EPOCH_MISMATCH``)를 다시 보내지 않고 실패로 올린다 - 엔진이 그 토픽의 번호 상태를
341
+ 새로 잡으므로 다시 보낸 배치가 중복으로 가려져 사라질 수 있기 때문이다. 비멱등(False)은 메타데이터를
342
+ 다시 받고 조용히 다시 보낸다.
343
+
319
344
  Args:
320
- b (bool): True 면 멱등 발행.
345
+ b (bool): True 면 멱등 발행, False 면 비멱등.
321
346
 
322
347
  Returns:
323
348
  ILProducerConfig: 체이닝을 위해 자기 자신.
324
349
 
325
350
  Raises:
326
- ValueError: (:meth:`validate` 시점) ``acks=0`` 과 함께 켰을 때.
351
+ ValueError: (:meth:`validate` 시점) 명시적으로 켜고 ``acks=0`` 이나 ``maxInFlight`` > 5 와 함께 썼을 때.
327
352
  응답을 받지 않는 모드에서는 중복 제거가 성립하지 않는다.
328
353
  """
329
- self._enableIdempotence = bool(b); return self
354
+ self._enableIdempotence = bool(b)
355
+ self._idempotenceSet = True
356
+ return self
330
357
 
331
358
  # ── 빌더 (snake 별칭) ───────────────────────────────────────────────
332
359
  # Java 이름 빌더와 동작이 같다. 상세 설명은 각 원본 메서드를 참조.
@@ -404,18 +431,18 @@ class ILProducerConfig:
404
431
  return self._lingerMs
405
432
 
406
433
  def get_batch_size(self) -> int:
407
- """한 봉투 누적 한도(바이트). 기본 16384.
434
+ """파티션 배치 하나의 크기 상한(바이트). 기본 262144.
408
435
 
409
436
  Returns:
410
- int: 한 봉투 누적 한도(바이트). 기본 16384.
437
+ int: 파티션 배치 하나의 크기 상한(바이트). 기본 262144.
411
438
  """
412
439
  return self._batchSize
413
440
 
414
441
  def get_max_in_flight(self) -> int:
415
- """동시 in-flight 배치 상한. 기본 5 (파이썬 구현은 실제로 항상 1).
442
+ """응답을 기다리는 봉투 수의 상한. 기본 5.
416
443
 
417
444
  Returns:
418
- int: 동시 in-flight 배치 상한. 기본 5 (파이썬 구현은 실제로 항상 1).
445
+ int: 응답을 기다리는 봉투 수의 상한. 기본 5.
419
446
  """
420
447
  return self._maxInFlight
421
448
 
@@ -484,12 +511,15 @@ class ILProducerConfig:
484
511
  return self._maxFrameLength
485
512
 
486
513
  def is_idempotence_enabled(self) -> bool:
487
- """멱등 발행이 켜져 있는지. 기본 False.
514
+ """실제로 쓰일 멱등 발행 여부. 기본 True.
488
515
 
489
516
  Returns:
490
- bool: 멱등 발행이 켜져 있는지. 기본 False.
517
+ bool: 멱등 발행 여부. 정하지 않았으면 기본(True)이되, ``acks=0`` 이거나 ``maxInFlight`` 가
518
+ 5 를 넘으면 False 다.
491
519
  """
492
- return self._enableIdempotence
520
+ if self._idempotenceSet:
521
+ return self._enableIdempotence
522
+ return self._acks != 0 and self._maxInFlight <= 5
493
523
 
494
524
  getAcks = get_acks
495
525
  getLingerMs = get_linger_ms
@@ -507,12 +537,13 @@ class ILProducerConfig:
507
537
 
508
538
  def validate(self) -> None:
509
539
  """조합 검증. producer 생성 시 호출된다."""
510
- if self._enableIdempotence and self._acks == 0:
540
+ # 0.7.1: 명시적으로 켰을 때만 막는다. 정하지 않은 기본 멱등은 is_idempotence_enabled() 에서 저절로 꺼진다
541
+ if self._idempotenceSet and self._enableIdempotence and self._acks == 0:
511
542
  raise ValueError(
512
543
  "enableIdempotence requires acks=1"
513
544
  )
514
545
  # 0.7.0: 엔진은 (pid, 파티션)마다 최근 배치 5개로 중복을 가린다 - 그보다 많이 겹쳐 보내면 재전송이 중복으로 가려지지 않는다
515
- if self._enableIdempotence and self._maxInFlight > 5:
546
+ if self._idempotenceSet and self._enableIdempotence and self._maxInFlight > 5:
516
547
  raise ValueError(
517
548
  "enableIdempotence requires maxInFlight <= 5 (the engine remembers the last 5 batches"
518
549
  " per partition; Kafka has the same limit)"
@@ -720,31 +751,75 @@ class ILRecordMetadata:
720
751
  return "ILRecordMetadata(%s[%d]@%d)" % (self.topic, self.partition, self.offset)
721
752
 
722
753
 
754
+ class _BatchResult:
755
+ """파티션 배치 하나의 결과(0.7.2). 같은 배치의 레코드 future 들이 함께 가리킨다 - 완결은 배치당 한 번이다."""
756
+
757
+ __slots__ = ("topic", "partition", "event", "base_offset", "timestamp", "error")
758
+
759
+ def __init__(self, topic: str | None, partition: int) -> None:
760
+ """배치 결과를 만든다.
761
+
762
+ Args:
763
+ topic (str | None): 토픽 이름.
764
+ partition (int): 파티션 번호.
765
+ """
766
+ self.topic = topic
767
+ self.partition = partition
768
+ self.event = threading.Event()
769
+ self.base_offset = -1
770
+ self.timestamp = 0
771
+ self.error: Exception | None = None
772
+
773
+ def complete(self, base_offset: int, timestamp: int) -> None:
774
+ """성공으로 정한다.
775
+
776
+ Args:
777
+ base_offset (int): 배치 첫 레코드의 오프셋(-1 = 모름).
778
+ timestamp (int): 적재 시각.
779
+ """
780
+ self.base_offset = base_offset
781
+ self.timestamp = timestamp
782
+ self.event.set()
783
+
784
+ def fail(self, error: Exception) -> None:
785
+ """실패로 정한다.
786
+
787
+ Args:
788
+ error (Exception): 실패 원인.
789
+ """
790
+ self.error = error
791
+ self.event.set()
792
+
793
+
723
794
  class ILPublishFuture:
724
795
  """발행 완결을 기다리는 핸들. :meth:`get` 이 :class:`ILRecordMetadata` 를 돌려준다.
725
796
 
726
797
  **동기 발행은 ``send(rec).get()``** 으로 쓴다.
727
798
  """
728
799
 
729
- def __init__(self, timeout_ms: int) -> None:
800
+ def __init__(self, timeout_ms: int, _result: "_BatchResult | None" = None, _index: int = 0) -> None:
730
801
  """**응용이 직접 만들지 않는다** - :meth:`ILTopicProducer.send` 가 돌려준다.
731
802
 
732
803
  Args:
733
804
  timeout_ms (int): :meth:`get` 에 인자를 주지 않았을 때 쓸 기본 대기
734
805
  시간(밀리초). producer 의 ``deliveryTimeoutMs`` 가 들어온다.
806
+ _result (_BatchResult | None): 내부용 - 이 레코드가 든 파티션 배치의 결과(배치의 레코드들이 함께 가리킨다).
807
+ _index (int): 내부용 - 배치 안 순번. 좌표는 배치 첫 오프셋 + 순번이다.
735
808
  """
736
- self._event = threading.Event()
809
+ # 0.7.2: 완결은 배치 결과 하나로 한다(레코드마다 Event 를 만들지 않는다). 좌표는 get() 때 만든다
810
+ self._result = _result if _result is not None else _BatchResult(None, -1)
811
+ self._index = _index
737
812
  self._meta: ILRecordMetadata | None = None
738
- self._error: Exception | None = None
739
813
  self._timeout_ms = timeout_ms
740
814
 
741
815
  def _complete(self, meta: ILRecordMetadata) -> None:
816
+ # 배치 없이 만든 future 용(호환)
742
817
  self._meta = meta
743
- self._event.set()
818
+ self._result.event.set()
744
819
 
745
820
  def _fail(self, error: Exception) -> None:
746
- self._error = error
747
- self._event.set()
821
+ self._result.error = error
822
+ self._result.event.set()
748
823
 
749
824
  def get(self, timeout_ms: int | None = None) -> ILRecordMetadata:
750
825
  """발행이 끝날 때까지 기다렸다가 좌표를 돌려준다.
@@ -766,12 +841,18 @@ class ILPublishFuture:
766
841
  완결은 producer 의 I/O 스레드가 한다(0.7.0). ``lingerMs`` 가 0 이어도 ``send()``
767
842
  가 돌아온 시점에 완결되어 있다는 보장은 없다 - 결과가 필요하면 이 메서드로 기다린다.
768
843
  """
844
+ r = self._result
769
845
  wait = (timeout_ms if timeout_ms is not None else self._timeout_ms) / 1000.0
770
- if not self._event.wait(wait if wait > 0 else None):
846
+ if not r.event.wait(wait if wait > 0 else None):
771
847
  raise ILException(TimeoutError("publish timed out"))
772
- if self._error is not None:
773
- raise self._error
774
- return self._meta
848
+ if r.error is not None:
849
+ raise r.error
850
+ m = self._meta
851
+ if m is None:
852
+ m = ILRecordMetadata(r.topic, r.partition, r.base_offset + self._index if r.base_offset >= 0 else -1,
853
+ r.timestamp)
854
+ self._meta = m
855
+ return m
775
856
 
776
857
  def is_done(self) -> bool:
777
858
  """완결(성공이든 실패든)되었는지.
@@ -779,7 +860,7 @@ class ILPublishFuture:
779
860
  Returns:
780
861
  bool: 결과가 정해졌으면 True. True 여도 :meth:`get` 이 예외를 올릴 수 있다.
781
862
  """
782
- return self._event.is_set()
863
+ return self._result.event.is_set()
783
864
 
784
865
  def cancel(self, may_interrupt_if_running: bool = False) -> bool:
785
866
  """발행은 취소할 수 없다. **항상 False**.
@@ -936,18 +1017,19 @@ def _as_il(exc: BaseException) -> Exception:
936
1017
  class _Batch:
937
1018
  """파티션 배치 1개 = 와이어의 PartitionBatch 1개. ``buf`` 의 앞 52바이트가 배치 헤더다."""
938
1019
 
939
- __slots__ = ("partition", "created", "batch_no", "buf", "count", "futures", "callbacks",
1020
+ __slots__ = ("partition", "created", "batch_no", "buf", "count", "result", "callbacks",
940
1021
  "closed", "done", "base_seq", "header_done", "header_pid", "retry_count",
941
1022
  "retry_at", "unknown", "size_drops", "solo")
942
1023
 
943
- def __init__(self, partition: int, created: float, batch_no: int) -> None:
1024
+ def __init__(self, partition: int, created: float, batch_no: int, topic: str = "") -> None:
944
1025
  self.partition = partition
945
1026
  self.created = created
946
1027
  #: 만든 순서. 파티션 안에서 보내야 하는 순서 = 이 순서
947
1028
  self.batch_no = batch_no
948
1029
  self.buf = bytearray(_BATCH_HEADER)
949
1030
  self.count = 0
950
- self.futures: list = []
1031
+ #: 이 배치 레코드들의 결과(0.7.2, 배치당 하나). 레코드 future 는 이것과 배치 안 순번만 든다
1032
+ self.result = _BatchResult(topic, partition)
951
1033
  self.callbacks: list | None = None
952
1034
  #: 더 붙이지 않는다(batchSize 에 닿음, drain 이 가져감, 되돌림)
953
1035
  self.closed = False
@@ -1001,16 +1083,18 @@ class ILTopicProducer:
1001
1083
  """토픽 하나에 묶인 발행자. ``ILQmgr.create_producer(topic, config)`` 로 얻는다. (0.7.0 발행 v2)
1002
1084
 
1003
1085
  producer 하나 = 토픽 하나 = 전용 연결 하나(clientName + ``"-PRD"``) + I/O 스레드 하나다. 생성할 때
1004
- 연결을 열고 토픽의 파티션 수·epoch·세그먼트 크기를 받아 둔다. 연결이 여러 개 필요하면 producer 를
1005
- 여러 개 만든다(예: 발행 스레드마다 하나).
1086
+ 연결을 열고 토픽의 파티션 수·epoch·세그먼트 크기를 받아 둔다. 한 토픽의 producer(연결)는 1~4개면
1087
+ 충분하다 - 엔진 7.0.1.3407 실측에서 8개로 늘리면 어느 크기든 오히려 줄었다. 큰 레코드(1KB 급)는 1개로도
1088
+ 디스크가 먼저 한계에 닿고, 작은 레코드(100B 급)는 4개 근처가 최대다.
1006
1089
 
1007
1090
  보장:
1008
1091
 
1009
1092
  - **순서**: 한 파티션 안에서는 ``send`` 순서대로 적재된다. 파티션 사이의 순서는 보장하지 않는다.
1010
1093
  비멱등에서 FLUSH TOPIC 과 겹친 배치를 다시 보낼 때는 이미 나가 있던 같은 파티션의 뒤 배치가
1011
1094
  먼저 적재될 수 있다.
1012
- - **전달**: ``acks=1`` + 재시도 = at-least-once. ``enableIdempotence(True)`` 면 재전송이 엔진에서 중복
1013
- 제거되어 유실/중복이 없다. ``acks=0`` = at-most-once.
1095
+ - **전달**: ``acks=1`` + 재시도 = at-least-once. 멱등(기본 켜짐)이면 producer 안의 재전송이 엔진에서
1096
+ 중복 제거되어 중복 적재가 없다(적재하지 못한 배치는 future 가 실패로 알린다). 앱이 ``send`` 를 다시
1097
+ 부르면 새 레코드라 멱등이어도 걸러지지 않는다. ``acks=0`` = at-most-once.
1014
1098
  - **재시도**: 연결 단절, 비멱등의 FLUSHED / epoch 불일치(적재되지 않은 것이 확정된 거절), 멱등의
1015
1099
  순서 오류다. 그 밖의 거절(토픽 없음, 너무 큼 등)은 재시도 없이 그 배치의 future 가 실패한다.
1016
1100
  PID 가 무효가 되면 PID 를 다시 받고 seq 를 0 부터 다시 매긴다.
@@ -1036,13 +1120,14 @@ class ILTopicProducer:
1036
1120
  port (int | None): 포트. ``host`` 에 ``:포트`` 가 붙어 있으면 생략한다.
1037
1121
  client_name (str): 큐 관리자에 등록될 클라이언트 이름. 기본 ``"PRODUCER"``.
1038
1122
  topic (str): 발행할 토픽 이름. **필수.** 이 producer 는 이 토픽에만 발행한다.
1039
- config (ILProducerConfig | None): 발행 설정. 기본 None 이면 전 항목 기본값.
1123
+ config (ILProducerConfig | None): 발행 설정. 기본 None 이면 전 항목 기본값. 생성 때 복사한다 -
1124
+ 이후 넘긴 객체를 바꿔도 이 producer 에는 반영되지 않는다.
1040
1125
 
1041
1126
  Raises:
1042
1127
  TypeError: ``topic`` 이 토픽 이름(str)이 아닐 때 - 0.6.x 의
1043
1128
  ``ILTopicProducer(host, port, name, config)`` 모양으로 부르면 여기서 멈춘다.
1044
- ValueError: ``topic`` 이 비었거나 ``config`` 조합이 잘못됐을 때(멱등 + ``acks=0``,
1045
- 멱등 + ``maxInFlight`` > 5).
1129
+ ValueError: ``topic`` 이 비었거나 ``config`` 조합이 잘못됐을 때(명시적으로 켠 멱등 + ``acks=0``,
1130
+ 명시적으로 켠 멱등 + ``maxInFlight`` > 5).
1046
1131
  ILOperationException: 주소 목록이 비었거나, 접속·토픽 조회를 거절당했을 때
1047
1132
  (없는 토픽이면 ``MIMQE_OBJECT_NOT_FOUND``).
1048
1133
  ILException: 후보 주소 어디에도 접속하지 못했을 때.
@@ -1058,7 +1143,8 @@ class ILTopicProducer:
1058
1143
  " (got %s) - since 0.7.0 a producer publishes to one topic" % type(topic).__name__)
1059
1144
  if not topic:
1060
1145
  raise ValueError("topic must not be empty - a producer publishes to one topic")
1061
- self.config = config or ILProducerConfig()
1146
+ # 0.7.2: 설정을 생성 때 복사한다(Kafka·자바와 같다) - 넘긴 객체를 나중에 바꿔도 이 producer 에는 반영되지 않는다
1147
+ self.config = copy.copy(config) if config is not None else ILProducerConfig()
1062
1148
  self.config.validate()
1063
1149
  cfg = self.config
1064
1150
  self.topic = topic
@@ -1075,6 +1161,13 @@ class ILTopicProducer:
1075
1161
  self._backoff = cfg.get_retry_backoff_ms() / 1000.0
1076
1162
  self._request_timeout = cfg.get_request_timeout_ms() / 1000.0
1077
1163
  self._delivery_timeout = cfg.get_delivery_timeout_ms() / 1000.0
1164
+ # 0.7.2: send() 가 레코드마다 부르던 설정 게터를 생성 때 한 번 읽는다
1165
+ self._delivery_timeout_ms = cfg.get_delivery_timeout_ms()
1166
+ self._max_request_size = cfg.get_max_request_size()
1167
+ self._max_frame_length = cfg.get_max_frame_length()
1168
+ self._buffer_memory = cfg.get_buffer_memory()
1169
+ self._max_block = cfg.get_max_block_ms() / 1000.0
1170
+ self._batch_size = cfg.get_batch_size()
1078
1171
  #: 봉투 본문(IL 헤더 뒤) 상한 = min(maxRequestSize, maxFrameLength - 25)
1079
1172
  self._max_env_content = min(cfg.get_max_request_size(),
1080
1173
  cfg.get_max_frame_length() - ILC.SZ_IL_HEADER)
@@ -1111,6 +1204,10 @@ class ILTopicProducer:
1111
1204
  self._used = 0
1112
1205
  self._sticky = -1
1113
1206
  self._sticky_bytes = 0
1207
+ #: 키 -> 파티션 캐시(0.7.2). 파티션 수가 바뀌면 비운다
1208
+ self._key_parts: dict = {}
1209
+ #: 깨울 신호를 이미 보냈고 I/O 스레드가 아직 비우지 않았다(0.7.2 - 신호를 합친다)
1210
+ self._wake_pending = False
1114
1211
  self._pid = -1
1115
1212
  #: drain 이 큐에서 꺼낸 배치를 아직 in-flight 에 올리지(acks=0 이면 완결하지) 않은 동안 1 - flush 가 먼저 돌아가지 않게
1116
1213
  self._draining = 0
@@ -1265,6 +1362,8 @@ class ILTopicProducer:
1265
1362
  self._parts = parts
1266
1363
  if self._sticky >= n:
1267
1364
  self._sticky = -1
1365
+ if n != self._partition_count:
1366
+ self._key_parts.clear()
1268
1367
  self._partition_count = n
1269
1368
  self._cv.notify_all()
1270
1369
  if len(self._next_seq) != n:
@@ -1355,22 +1454,21 @@ class ILTopicProducer:
1355
1454
  rec_size = _RECORD_OVERHEAD + kl + pl + len(value)
1356
1455
  # 이 레코드 혼자 실린 봉투의 본문 크기
1357
1456
  alone = _ENVELOPE_FIXED + len(self._topic_bytes) + _BATCH_HEADER + rec_size
1358
- if alone > cfg.get_max_request_size():
1457
+ if alone > self._max_request_size:
1359
1458
  raise ILOperationException("MIMQE_MSG_SIZE_OVER (record too large: %d > maxRequestSize %d - envelope carrying"
1360
- " this record alone)" % (alone, cfg.get_max_request_size()))
1459
+ " this record alone)" % (alone, self._max_request_size))
1361
1460
  # 엔진은 Runtime@MaxFrameLength(기본 128MiB) 를 넘는 프레임을 사유 없이 끊는다 - 보내기 전에 막는다
1362
- if alone + ILC.SZ_IL_HEADER > cfg.get_max_frame_length():
1461
+ if alone + ILC.SZ_IL_HEADER > self._max_frame_length:
1363
1462
  raise ILOperationException(
1364
1463
  "MIMQE_MSG_SIZE_OVER (record too large: frame %d > maxFrameLength %d - the engine drops the connection"
1365
1464
  " over its Runtime@MaxFrameLength; raise both if the server allows more)"
1366
- % (alone + ILC.SZ_IL_HEADER, cfg.get_max_frame_length()))
1465
+ % (alone + ILC.SZ_IL_HEADER, self._max_frame_length))
1367
1466
  rec = b"".join((_I32.pack(kl if key else -1), key or b"",
1368
1467
  _I32.pack(pl if props else -1), props or b"",
1369
1468
  _I32.pack(len(value)), value))
1370
- future = ILPublishFuture(cfg.get_delivery_timeout_ms())
1371
1469
  with self._cv:
1372
- # maxBlockMs 는 버퍼 대기의 상한이다(Kafka max.block.ms)
1373
- deadline = time.monotonic() + cfg.get_max_block_ms() / 1000.0
1470
+ # maxBlockMs 는 버퍼 대기의 상한이다(Kafka max.block.ms). 기한은 기다려야 할 때만 잡는다
1471
+ deadline = None
1374
1472
  partition = self._choose_partition(record.partition, key)
1375
1473
  while True:
1376
1474
  if self._fatal is not None:
@@ -1392,16 +1490,18 @@ class ILTopicProducer:
1392
1490
  fresh = (b is None or b.closed or len(b.buf) + rec_size > self._batch_limit
1393
1491
  or (seg > 0 and len(b.buf) + rec_size + (b.count + 1) * self._v3_per_record > seg))
1394
1492
  need = rec_size + (_BATCH_HEADER if fresh else 0)
1395
- if need > cfg.get_buffer_memory():
1493
+ if need > self._buffer_memory:
1396
1494
  raise ILOperationException("MIMQE_MSG_SIZE_OVER (record too large: needs %d bytes of buffer"
1397
- " > bufferMemory %d)" % (need, cfg.get_buffer_memory()))
1398
- if self._used + need > cfg.get_buffer_memory():
1495
+ " > bufferMemory %d)" % (need, self._buffer_memory))
1496
+ if self._used + need > self._buffer_memory:
1399
1497
  # 버퍼 확보(가득이면 maxBlockMs 대기). 기다리는 사이 배치가 나가거나 파티션 수가 바뀔 수 있어 처음부터 다시 본다
1498
+ if deadline is None:
1499
+ deadline = time.monotonic() + self._max_block
1400
1500
  remain = deadline - time.monotonic()
1401
1501
  if remain <= 0:
1402
1502
  # 부하 문제다(Kafka BufferExhaustedException) - 큐 가득과 같은 분류(재시도 가능)로 올린다
1403
1503
  raise ILOperationException("MIMQE_QUEUE_FULL (buffer memory full %d/%d - blocked over maxBlockMs)"
1404
- % (self._used, cfg.get_buffer_memory()))
1504
+ % (self._used, self._buffer_memory))
1405
1505
  self._cv.wait(remain)
1406
1506
  continue
1407
1507
  wake = False
@@ -1409,14 +1509,14 @@ class ILTopicProducer:
1409
1509
  if b is not None:
1410
1510
  b.closed = True
1411
1511
  self._batch_counter += 1
1412
- b = _Batch(partition, time.monotonic(), self._batch_counter)
1512
+ b = _Batch(partition, time.monotonic(), self._batch_counter, self.topic)
1413
1513
  q.append(b)
1414
1514
  self._queued += 1
1415
1515
  self._used += _BATCH_HEADER
1416
1516
  wake = True
1417
1517
  b.buf += rec
1418
1518
  self._used += rec_size
1419
- b.futures.append(future)
1519
+ future = ILPublishFuture(self._delivery_timeout_ms, b.result, b.count)
1420
1520
  if callback is not None:
1421
1521
  if b.callbacks is None:
1422
1522
  b.callbacks = [None] * b.count
@@ -1445,7 +1545,15 @@ class ILTopicProducer:
1445
1545
  % (explicit, n - 1))
1446
1546
  return explicit
1447
1547
  if key:
1448
- return partition_for_key(key, n)
1548
+ # 0.7.2: 같은 키는 같은 파티션이다 - 파티션 수가 그대로인 동안 결과를 캐시한다(순수 파이썬 murmur2 가 레코드당 수 us)
1549
+ p = self._key_parts.get(key)
1550
+ if p is None:
1551
+ p = partition_for_key(key, n)
1552
+ if len(key) <= _KEY_CACHE_MAX_KEY:
1553
+ if len(self._key_parts) >= _KEY_CACHE_MAX:
1554
+ self._key_parts.clear()
1555
+ self._key_parts[key] = p
1556
+ return p
1449
1557
  if self._sticky < 0 or self._sticky >= n:
1450
1558
  self._sticky = random.randrange(n)
1451
1559
  self._sticky_bytes = 0
@@ -1454,7 +1562,7 @@ class ILTopicProducer:
1454
1562
  def _sticky_advance(self, nbytes: int) -> None:
1455
1563
  """키 없는 레코드를 붙인 만큼 센다. ``batchSize`` 를 채우면 다음 파티션으로 넘긴다(Kafka 3.3+ 균일 스티키)."""
1456
1564
  self._sticky_bytes += nbytes
1457
- if self._sticky_bytes >= self.config.get_batch_size():
1565
+ if self._sticky_bytes >= self._batch_size:
1458
1566
  self._sticky = (self._sticky + 1) % self._partition_count
1459
1567
  self._sticky_bytes = 0
1460
1568
 
@@ -1573,10 +1681,15 @@ class ILTopicProducer:
1573
1681
 
1574
1682
  # ── I/O 스레드 ───────────────────────────────────────────────────────
1575
1683
  def _wakeup(self) -> None:
1684
+ # 0.7.2: 깨울 신호가 이미 가 있으면(I/O 스레드가 아직 비우지 않았으면) 다시 쓰지 않는다 - 배치를 열고 닫을 때마다 쓰던
1685
+ # 시스템 호출을 합친다. I/O 스레드는 신호를 비운 뒤에 이 표시를 내리고, 그다음에 큐를 본다
1686
+ if self._wake_pending:
1687
+ return
1688
+ self._wake_pending = True
1576
1689
  try:
1577
1690
  self._wake_w.send(b"\x00")
1578
1691
  except OSError:
1579
- pass
1692
+ self._wake_pending = False
1580
1693
 
1581
1694
  def _drain_wake(self) -> None:
1582
1695
  try:
@@ -1584,6 +1697,8 @@ class ILTopicProducer:
1584
1697
  pass
1585
1698
  except OSError:
1586
1699
  pass
1700
+ # 비운 뒤에 내린다. 먼저 내리면 비우는 사이 온 신호를 먹고도 표시가 올라간 채 남아 다음 깨우기가 막힐 수 있다
1701
+ self._wake_pending = False
1587
1702
 
1588
1703
  def _wake_waiters(self) -> None:
1589
1704
  with self._cv:
@@ -1942,45 +2057,56 @@ class ILTopicProducer:
1942
2057
  raise ConnectionError(results[0][4] or "MIMQE_INVALID_PID")
1943
2058
  now = time.monotonic()
1944
2059
  again = []
1945
- for b, (code, off, ts, exp, reason) in zip(env.batches, results):
1946
- # seq 틈: 이 배치의 어느 시도도 적재됐을 수 없는데(결과를 모른 적이 없다) 엔진이 중복이라 한다 = 같은 파티션의 뒤
1947
- # 배치가 먼저 기준선으로 적재돼(두 봉투 사이 리더 교체 등) 이 번호가 틈으로 남았다. 성공으로 올리면 조용한 유실이다.
1948
- if code == ILC.MIMQ_TOPIC_PUBLISH_DUPLICATE and self._idem and b.base_seq >= 0 and not b.unknown:
1949
- self._complete_batch(b, -1, 0, ILOperationException(
1950
- "MIMQE_OUT_OF_ORDER_SEQUENCE (sequence gap: partition %d seq %d..%d was never stored but the engine"
1951
- " answered duplicate - a later batch of this partition was stored first)"
1952
- % (b.partition, b.base_seq, b.base_seq + b.count - 1)))
1953
- continue
1954
- if code == ILC.MIMQ_SUCCESSFUL_TOPIC_PUBLISH or code == ILC.MIMQ_TOPIC_PUBLISH_DUPLICATE:
1955
- # 중복 응답은 원 좌표로 성공한다. 엔진 링 밖이면 baseOffset 이 -1 이다.
1956
- self._pid_resets = 0
1957
- if self._idem and b.base_seq >= 0 and b.partition < len(self._acked_seq):
1958
- last = b.base_seq + b.count - 1
1959
- if last > self._acked_seq[b.partition]:
1960
- self._acked_seq[b.partition] = last
1961
- self._complete_batch(b, off, ts, None)
1962
- continue
1963
- # epoch 가 바뀌었거나(FLUSH / 재생성), 토픽이 비워지는 중이거나, 세그먼트가 줄었다 - 메타데이터를 다시 받는다
1964
- if code in (ILC.MIMQ_TOPIC_PUBLISH_EPOCH_MISMATCH, ILC.MIMQ_TOPIC_PUBLISH_FLUSHED,
1965
- ILC.MIMQ_TOPIC_PUBLISH_TOO_LARGE):
1966
- self._meta_stale = True
1967
- expected = exp if code == ILC.MIMQ_OUT_OF_ORDER_SEQUENCE else -1
1968
- if self._idem and b.base_seq >= 0:
1969
- # 배치별 거절 = 이번 시도는 적재되지 않았다(엔진 계약). 번호를 돌려받고 재번호를 건다.
1970
- # 단 앞선 시도가 답 없이 끝난 배치(unknown)는 그때 적재됐을 수 있다 - 순서 오류가 아니면 번호를
1971
- # 돌려받지 않는다. 돌려받아 다음 새 배치에 주면 그 배치가 엔진에서 중복 응답으로 사라진다(유실).
1972
- if expected >= 0 or not b.unknown:
1973
- self._note_rejected(b.partition, b.base_seq, expected)
1974
- b.base_seq = -1
1975
- b.header_done = False
1976
- if self._can_retry(b, code, expected >= 0, now):
1977
- b.retry_count += 1
1978
- b.retry_at = now + self._backoff
1979
- again.append(b)
1980
- else:
1981
- self._complete_batch(b, -1, 0, _batch_error(code, reason))
2060
+ # 0.7.2: 이 ACK 에서 완결한 배치들의 버퍼는 아래 잠금 한 번에 반납한다(배치마다 잠금을 잡지 않는다)
2061
+ freed = 0
2062
+ try:
2063
+ for b, (code, off, ts, exp, reason) in zip(env.batches, results):
2064
+ # seq 틈: 이 배치의 어느 시도도 적재됐을 수 없는데(결과를 모른 적이 없다) 엔진이 중복이라 한다 = 같은 파티션의
2065
+ # 뒤 배치가 먼저 기준선으로 적재돼(두 봉투 사이 리더 교체 등) 이 번호가 틈으로 남았다. 성공으로 올리면 조용한 유실이다.
2066
+ if code == ILC.MIMQ_TOPIC_PUBLISH_DUPLICATE and self._idem and b.base_seq >= 0 and not b.unknown:
2067
+ freed += self._complete_batch(b, -1, 0, ILOperationException(
2068
+ "MIMQE_OUT_OF_ORDER_SEQUENCE (sequence gap: partition %d seq %d..%d was never stored but the engine"
2069
+ " answered duplicate - a later batch of this partition was stored first)"
2070
+ % (b.partition, b.base_seq, b.base_seq + b.count - 1)), release=False)
2071
+ continue
2072
+ if code == ILC.MIMQ_SUCCESSFUL_TOPIC_PUBLISH or code == ILC.MIMQ_TOPIC_PUBLISH_DUPLICATE:
2073
+ # 중복 응답은 원 좌표로 성공한다. 엔진 링 밖이면 baseOffset 이 -1 이다.
2074
+ self._pid_resets = 0
2075
+ if self._idem and b.base_seq >= 0 and b.partition < len(self._acked_seq):
2076
+ last = b.base_seq + b.count - 1
2077
+ if last > self._acked_seq[b.partition]:
2078
+ self._acked_seq[b.partition] = last
2079
+ freed += self._complete_batch(b, off, ts, None, release=False)
2080
+ continue
2081
+ # epoch 가 바뀌었거나(FLUSH / 재생성), 토픽이 비워지는 중이거나, 세그먼트가 줄었다 - 메타데이터를 다시 받는다
2082
+ if code in (ILC.MIMQ_TOPIC_PUBLISH_EPOCH_MISMATCH, ILC.MIMQ_TOPIC_PUBLISH_FLUSHED,
2083
+ ILC.MIMQ_TOPIC_PUBLISH_TOO_LARGE):
2084
+ self._meta_stale = True
2085
+ expected = exp if code == ILC.MIMQ_OUT_OF_ORDER_SEQUENCE else -1
2086
+ if self._idem and b.base_seq >= 0:
2087
+ # 배치별 거절 = 이번 시도는 적재되지 않았다(엔진 계약). 번호를 돌려받고 재번호를 건다.
2088
+ # 단 앞선 시도가 답 없이 끝난 배치(unknown)는 그때 적재됐을 수 있다 - 순서 오류가 아니면 번호를
2089
+ # 돌려받지 않는다. 돌려받아 다음 새 배치에 주면 그 배치가 엔진에서 중복 응답으로 사라진다(유실).
2090
+ if expected >= 0 or not b.unknown:
2091
+ self._note_rejected(b.partition, b.base_seq, expected)
2092
+ b.base_seq = -1
2093
+ b.header_done = False
2094
+ if self._can_retry(b, code, expected >= 0, now):
2095
+ b.retry_count += 1
2096
+ b.retry_at = now + self._backoff
2097
+ again.append(b)
2098
+ else:
2099
+ freed += self._complete_batch(b, -1, 0, _batch_error(code, reason), release=False)
2100
+ except BaseException:
2101
+ # 도중에 끊겨도 이미 완결한 배치의 버퍼는 돌려준다
2102
+ if freed:
2103
+ with self._cv:
2104
+ self._used -= freed
2105
+ self._cv.notify_all()
2106
+ raise
1982
2107
  gone: list = []
1983
2108
  with self._cv:
2109
+ self._used -= freed
1984
2110
  # 배치는 되돌린 것을 큐에 넣은 **뒤에** in-flight 에서 뺀다. 먼저 빼면 그 사이 flush() 가 비었다고 보고 돌아간다.
1985
2111
  if again:
1986
2112
  gone = self._requeue_batches(again)
@@ -2059,30 +2185,40 @@ class ILTopicProducer:
2059
2185
  self._queued += 1
2060
2186
  return gone
2061
2187
 
2062
- def _complete_batch(self, b: _Batch, base_offset: int, timestamp: int, err: Exception | None) -> None:
2188
+ def _complete_batch(self, b: _Batch, base_offset: int, timestamp: int, err: Exception | None,
2189
+ release: bool = True) -> int:
2190
+ """배치 하나를 완결한다. 레코드 future 들은 배치 결과 하나를 함께 가리키므로 결과를 한 번 정하면 모두 풀린다(0.7.2).
2191
+
2192
+ ``release`` 가 False 면 버퍼 반납(``self._used``)과 대기자 깨우기를 부른 쪽이 한꺼번에 한다 -
2193
+ ACK 하나에 든 배치들을 잠금 한 번에 반납하려는 것이다. 반납할 바이트 수를 돌려준다.
2194
+ """
2063
2195
  if b.done:
2064
- return
2196
+ return 0
2065
2197
  b.done = True
2066
- with self._cv:
2067
- self._used -= len(b.buf)
2068
- self._cv.notify_all()
2069
- futures, callbacks = b.futures, b.callbacks
2070
- b.futures = []
2198
+ freed = len(b.buf)
2199
+ if release:
2200
+ with self._cv:
2201
+ self._used -= freed
2202
+ self._cv.notify_all()
2203
+ r = b.result
2204
+ if err is None:
2205
+ r.complete(base_offset, timestamp)
2206
+ else:
2207
+ r.fail(err)
2208
+ callbacks = b.callbacks
2071
2209
  b.callbacks = None
2072
- topic = self.topic
2073
- for i, fut in enumerate(futures):
2074
- meta = None
2075
- if err is None:
2076
- meta = ILRecordMetadata(topic, b.partition, base_offset + i if base_offset >= 0 else -1, timestamp)
2077
- fut._complete(meta)
2078
- else:
2079
- fut._fail(err)
2080
- cb = callbacks[i] if callbacks is not None else None
2081
- if cb is not None:
2210
+ if callbacks:
2211
+ topic = self.topic
2212
+ for i, cb in enumerate(callbacks):
2213
+ if cb is None:
2214
+ continue
2215
+ meta = (ILRecordMetadata(topic, b.partition, base_offset + i if base_offset >= 0 else -1, timestamp)
2216
+ if err is None else None)
2082
2217
  try:
2083
2218
  cb(meta, err)
2084
2219
  except Exception: # noqa: BLE001 - 콜백 예외는 무시한다(계약)
2085
2220
  pass
2221
+ return freed
2086
2222
 
2087
2223
  def _fail_batches(self, batches, err: Exception | None) -> None:
2088
2224
  """파티션이 없어진 배치 등을 실패시킨다(self._cv 밖에서 부른다)."""
@@ -1139,7 +1139,7 @@ class ILQmgr:
1139
1139
  Args:
1140
1140
  topic (str): 발행할 토픽 이름. 이 producer 는 이 토픽에만 발행한다.
1141
1141
  config (ILProducerConfig | None): 발행 설정. 기본 None 이면 전 항목
1142
- 기본값(acks=1 / linger 0 / batch 256KB / maxInFlight 5 / 멱등 꺼짐).
1142
+ 기본값(acks=1 / linger 0 / batch 256KB / maxInFlight 5 / 멱등 켜짐).
1143
1143
 
1144
1144
  Returns:
1145
1145
  ILTopicProducer: 발행자. 다 쓰면 ``close()`` 로 닫는다.
@@ -1149,13 +1149,16 @@ class ILQmgr:
1149
1149
  부르면 여기서 멈춘다.
1150
1150
  ILOperationException: 아직 :meth:`connect` 하지 않았을 때, 없는 토픽일 때
1151
1151
  (``MIMQE_OBJECT_NOT_FOUND``).
1152
- ValueError: ``config`` 조합이 잘못됐을 때(멱등 + ``acks=0``, 멱등 + ``maxInFlight`` > 5).
1152
+ ValueError: ``config`` 조합이 잘못됐을 때(명시적으로 켠 멱등 + ``acks=0``, 명시적으로 켠 멱등 +
1153
+ ``maxInFlight`` > 5).
1153
1154
  ILException: 발행 전용 연결을 맺지 못했을 때.
1154
1155
 
1155
1156
  Note:
1156
1157
  발행자는 **전용 연결**을 하나 연다(클라이언트 이름 뒤에 ``"-PRD"`` 가 붙는다). 생성할 때 토픽의
1157
1158
  파티션 수를 받아 두고, I/O 스레드가 봉투를 겹쳐 보낸다. 토픽이 여러 개면 토픽마다 producer 를
1158
- 만들고, 연결이 더 필요하면 producer 를 더 만든다(예: 발행 스레드마다 하나).
1159
+ 만든다. 한 토픽의 producer 는 1~4개면 충분하다 - 엔진 7.0.1.3407 실측에서 8개로 늘리면 어느 크기든
1160
+ 오히려 줄었다. 큰 레코드(1KB 급)는 1개로도 디스크가 먼저 한계에 닿고, 작은 레코드(100B 급)는 4개
1161
+ 근처가 최대다.
1159
1162
 
1160
1163
  Example::
1161
1164
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jetstream-api
3
- Version: 0.7.0
3
+ Version: 0.7.2
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,28 @@ Python으로 작성된 ILink 클라이언트 API입니다.
54
54
  ## 설치 방법
55
55
  pip install jetstream-api
56
56
 
57
+ ## 0.7.2 변경 - batchSize 기본값 262144 로 되돌림, 발행 경로 CPU 절감 (자바 v2.3.2 와 같음)
58
+
59
+ - `batchSize` 기본값을 **262144** 로 되돌렸습니다. 0.7.1 의 16384 에서 파이썬 클라는 처리량이 24~35% 낮았습니다(GIL 이
60
+ 천장이라 배치마다 드는 비용이 그대로 처리량에서 빠집니다). 멱등 기본 켜짐은 그대로입니다.
61
+ - 발행 경로 CPU 를 줄였습니다(API 는 그대로입니다). 레코드 future 가 배치 결과 하나를 함께 가리키고(완결은 배치당 한 번),
62
+ 키 -> 파티션 결과를 캐시하고, `send()` 가 설정값을 레코드마다 다시 읽지 않고, I/O 스레드를 깨우는 신호를 합치고,
63
+ ACK 하나의 배치 완결을 잠금 한 번에 묶습니다.
64
+ - producer 는 만들 때 설정 객체를 **복사**합니다(자바 v2.3.2 도 같고, Kafka 와 같습니다). 만든 뒤 넘긴 객체를 바꿔도 그
65
+ producer 에는 반영되지 않습니다 - 설정을 바꾸려면 producer 를 새로 만드십시오.
66
+ - 문서: 멱등이 거르는 것은 producer 안의 재전송뿐입니다 - 앱이 `send` 를 다시 부르면 새 레코드로 적재됩니다.
67
+
68
+ ## 0.7.1 변경 - producer 기본값: 멱등 켜짐, batchSize 16384 (자바 v2.3.1 과 같음)
69
+
70
+ - `enableIdempotence` 기본값이 **켜짐**입니다. 멱등을 직접 정하지 않았으면 `acks(0)` 이나 `maxInFlight` 가 5 를 넘을 때
71
+ 멱등이 저절로 꺼집니다. `enableIdempotence(True)` 를 명시하고 그 둘과 함께 쓰면 전처럼 생성 때 `ValueError` 입니다.
72
+ - 멱등이면 토픽을 비우거나(FLUSH) epoch 가 바뀐 순간 걸려 있던 배치를 다시 보내지 않고 실패로 올립니다
73
+ (`MIMQE_TOPIC_FLUSHED` / `MIMQE_TOPIC_EPOCH_MISMATCH`). 0.7.0 기본처럼 조용히 다시 보내려면 `enableIdempotence(False)` 를
74
+ 명시하십시오.
75
+ - `batchSize` 기본값이 **16384** 입니다(파티션 배치 하나의 상한). 0.7.0 은 262144 였습니다 - 처리량이 모자라면
76
+ `batchSize(262144)` 처럼 늘리십시오.
77
+ - producer 수 안내: 한 토픽의 producer(연결)는 1~4개면 충분합니다(엔진 7.0.1.3407 실측).
78
+
57
79
  ## 0.7.0 변경 - 발행 v2: producer 하나 = 토픽 하나, 파티션별 배치, 봉투 겹쳐 보내기 (엔진 v2 필요)
58
80
 
59
81
  발행 경로를 엔진의 새 발행 형식(v2)으로 바꿨습니다. **엔진도 v2 판이어야 합니다** - 옛 엔진에는 발행이 되지 않고
@@ -61,7 +83,8 @@ pip install jetstream-api
61
83
 
62
84
  - **producer 하나 = 토픽 하나 = 연결 하나.** `qmgr.create_producer("ORDER.EVENT", config)` 로 만듭니다. 생성할 때 토픽의
63
85
  파티션 수를 받아 두므로 없는 토픽이면 생성 단계에서 `MIMQE_OBJECT_NOT_FOUND` 로 실패합니다. 토픽이 여러 개면 토픽마다
64
- producer 를 만들고, 연결이 더 필요하면 producer 를 더 만듭니다(예: 발행 스레드마다 하나).
86
+ producer 를 만듭니다. 한 토픽의 producer(연결)는 1~4개면 충분합니다 - 엔진 7.0.1.3407 실측에서 8개로 늘리면 어느 크기든
87
+ 오히려 줄었습니다. 큰 레코드(1KB 급)는 1개로도 디스크가 먼저 한계에 닿고, 작은 레코드(100B 급)는 4개 근처가 최대입니다.
65
88
  - 레코드 토픽이 producer 토픽과 다르면 `send()` 가 `MIMQE_INVALID_ARGUMENT` 로 거부합니다.
66
89
  - 옛 `create_producer(config)` / `ILTopicProducer(host, port, name, config)` 모양으로 부르면 `TypeError` 가 납니다.
67
90
  - 배치는 **파티션마다** 모입니다. `batchSize`(기본 **262144**, 자바와 같음)는 파티션 배치 하나의 상한입니다. 보낼 때는
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jetstream-api"
7
- version = "0.7.0"
7
+ version = "0.7.2"
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