jetstream-api 0.7.3__tar.gz → 0.8.0__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.7.3/jetstream_api.egg-info → jetstream_api-0.8.0}/PKG-INFO +105 -2
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/README.md +102 -1
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/__init__.py +1 -1
- jetstream_api-0.8.0/ilink/_codec.py +370 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/admintopic.py +22 -4
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/common.py +5 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/connectivity.py +75 -38
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/msg.py +134 -48
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/pattern.py +193 -33
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/producer.py +210 -12
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/qmgr.py +8 -1
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/topic.py +204 -31
- {jetstream_api-0.7.3 → jetstream_api-0.8.0/jetstream_api.egg-info}/PKG-INFO +105 -2
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/jetstream_api.egg-info/SOURCES.txt +1 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/jetstream_api.egg-info/requires.txt +3 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/pyproject.toml +4 -1
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/LICENSE.txt +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/admin.py +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/exception.py +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/partitioner.py +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/properties.py +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/py.typed +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/ilink/util.py +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/jetstream_api.egg-info/dependency_links.txt +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/jetstream_api.egg-info/top_level.txt +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/setup.cfg +0 -0
- {jetstream_api-0.7.3 → jetstream_api-0.8.0}/tests/test_example.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jetstream-api
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
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
|
|
@@ -40,6 +40,8 @@ Classifier: Topic :: System :: Networking
|
|
|
40
40
|
Requires-Python: >=3.9
|
|
41
41
|
Description-Content-Type: text/markdown
|
|
42
42
|
License-File: LICENSE.txt
|
|
43
|
+
Provides-Extra: zstd
|
|
44
|
+
Requires-Dist: zstandard>=0.22; extra == "zstd"
|
|
43
45
|
Provides-Extra: test
|
|
44
46
|
Requires-Dist: docstring_parser>=0.16; extra == "test"
|
|
45
47
|
Dynamic: license-file
|
|
@@ -49,11 +51,112 @@ Dynamic: license-file
|
|
|
49
51
|
Python으로 작성된 ILink 클라이언트 API입니다.
|
|
50
52
|
|
|
51
53
|
## 시스템 요구 사항
|
|
52
|
-
시스템에
|
|
54
|
+
시스템에 Python 3.9 이상, pip가 설치되어 있어야 합니다. 필수 의존성은 없습니다(표준 라이브러리만 씁니다).
|
|
55
|
+
zstd 배치 압축을 쓰려면 `pip install "jetstream-api[zstd]"` 로 설치합니다(Python 3.14 이상은 표준 라이브러리로 됩니다).
|
|
53
56
|
|
|
54
57
|
## 설치 방법
|
|
55
58
|
pip install jetstream-api
|
|
56
59
|
|
|
60
|
+
## 0.8.0 변경 - 배치 압축 gzip / zstd (자바 v2.4.0 과 같음)
|
|
61
|
+
|
|
62
|
+
- **엔진 요구**: 압축 배치는 **엔진 7.0.1.3419 이상**(CMPR-1)에서만 받습니다. 그보다 옛 엔진은 압축 배치를 알아보지 못해
|
|
63
|
+
거절합니다. 기본값은 압축하지 않음(`none`)이라 설정하지 않으면 동작이 그대로입니다.
|
|
64
|
+
- **producer 설정** - 뜻과 기본값이 Kafka `compression.type` / `compression.gzip.level` / `compression.zstd.level` 과 같습니다:
|
|
65
|
+
- `compressionType("none" | "gzip" | "zstd")` (`compression_type`) - 기본 `"none"`. 소문자 그대로 씁니다(자바 / Kafka 와 같습니다).
|
|
66
|
+
`lz4` / `snappy` 는 지원하지 않습니다(`ValueError`).
|
|
67
|
+
- `compressionGzipLevel(n)` (`compression_gzip_level`) - `-1`(zlib 기본 레벨 6, 기본값) 또는 `1`~`9`.
|
|
68
|
+
- `compressionZstdLevel(n)` (`compression_zstd_level`) - `-131072`~`22`, 기본 `3`.
|
|
69
|
+
- 게터 `get_compression_type()` / `get_compression_gzip_level()` / `get_compression_zstd_level()`
|
|
70
|
+
(camelCase `getCompressionType()` / `getCompressionGzipLevel()` / `getCompressionZstdLevel()`).
|
|
71
|
+
- 범위 밖 값은 빌더에서 곧바로 `ValueError` 입니다. 직접 대입(`cfg.compressionType = "lz4"`)은 producer 생성 때
|
|
72
|
+
(`validate()`) 막습니다. 설정은 예전처럼 producer 생성 때 복사합니다.
|
|
73
|
+
- **zstd 는 선택 설치입니다**: `pip install "jetstream-api[zstd]"`(`zstandard>=0.22`). 파이썬 3.14 이상이면 표준 라이브러리
|
|
74
|
+
`compression.zstd` 를 먼저 쓰므로 설치하지 않아도 됩니다. gzip 은 표준 라이브러리(zlib)만 씁니다 - 필수 의존성은 여전히
|
|
75
|
+
없습니다. zstd 모듈이 없으면 `compressionType("zstd")` producer 생성이 연결하기 전에 `ILOperationException`
|
|
76
|
+
(`MIMQE_NOT_SUPPORTED (compressionType zstd: no zstd module - pip install ...)`)으로 멈추고, zstd 배치를 받은 구독 read 는
|
|
77
|
+
`ILException`(원인 `MIMQE_NOT_SUPPORTED (codec 4 zstd: no zstd module - pip install ...)`)입니다(자바와 같은 사유). 이 read
|
|
78
|
+
오류는 `MIMQC_USER_FAULT` / `MIMQE_NOT_SUPPORTED` 이고 그 배치에서 멈춥니다(아래 "풀지 못한 배치").
|
|
79
|
+
- **발행**: 파티션 배치를 보낼 때 레코드 열만 한 번 압축합니다(배치 헤더 52바이트는 그대로, `recordCount` 는 원래 건수, 배치
|
|
80
|
+
CRC 는 압축한 바이트가 대상). 재전송(연결 끊김, 순서 오류 재번호, PID 재발급)은 같은 압축 바이트에 헤더와 CRC 만 새로
|
|
81
|
+
씁니다. 압축해도 줄지 않는 배치(이미 압축된 자료, 무작위 바이트)는 압축하지 않고 보냅니다 - 한 토픽에 섞여도 됩니다.
|
|
82
|
+
`batchSize` 와 레코드 크기 검사(`maxRequestSize`, `maxFrameLength`, 토픽 세그먼트, `bufferMemory`)는 압축 **전** 크기로
|
|
83
|
+
셉니다(Kafka 와 같습니다). 봉투에 배치를 채울 때는 압축한 **뒤** 길이로 세므로 봉투 하나에 배치가 더 실립니다.
|
|
84
|
+
- **압축은 producer 의 I/O 스레드가 합니다**: `send()` 는 예전처럼 배치 버퍼에 붙이기만 합니다. zlib / zstd 는 압축하는 동안
|
|
85
|
+
GIL 을 놓으므로 `send()` 를 부르는 스레드와 겹쳐 돕니다. 대신 producer 하나의 압축은 한 스레드라, 느린 코덱(gzip 레벨 6 은
|
|
86
|
+
텍스트 배치에서 대략 60~70MB/s - 엔진 설계 문서의 측정)은 그 producer 의 발행 천장이 될 수 있습니다. zstd 나 낮은 gzip
|
|
87
|
+
레벨을 쓰거나 producer 를 늘리세요(한 토픽에 1~4개 안내는 그대로입니다).
|
|
88
|
+
- **소비**: 구독 read(배치 read 2042)와 패턴 구독(다중 토픽 read 2046)이 배치마다 코덱을 보고 풉니다. 한 토픽 / 한 응답에
|
|
89
|
+
무압축 / gzip / zstd 배치가 섞여도 됩니다. 오프셋과 커서 앞 레코드 빼기는 그대로입니다.
|
|
90
|
+
- **풀지 못한 배치는 그 자리에서 멈춥니다(stop-in-place)**: 풀지 못하는 배치 - 모르는 코덱 `MIMQE_TOPIC_UNSUPPORTED_CODEC (codec N)`,
|
|
91
|
+
zstd 모듈 없음, 깨짐 / 잘림 / 뒤에 남은 바이트 / 푼 크기 128MiB 초과 `MIMQE_TOPIC_CODEC_ERROR (gzip: ...)` / `(zstd: ...)`, 배치
|
|
92
|
+
CRC 불일치, 푼 레코드 수 / 길이가 헤더와 다름 - 를 만나도 응답 전체를 버리지 않습니다. 버리면 엔진 읽기 위치만 지나가 그 응답의
|
|
93
|
+
다른 배치까지 건너뛰고 다음 AUTO 커밋이 그 구간을 덮습니다(유실). Kafka 컨슈머처럼 그 자리에서 멈춥니다:
|
|
94
|
+
- 그 배치(파티션 P, baseOffset B) 앞에 푼 레코드와 같은 응답의 **다른 파티션** 레코드는 여느 때처럼 줍니다(`COMMIT_AUTO` 커밋은
|
|
95
|
+
앱에 준 것만). 같은 응답의 P 뒤 배치는 버리고, P 는 구독 연결로 B 로 되감습니다(`offset:B;partition:P` seek - 커서가 B 보다
|
|
96
|
+
뒤면 커서로). 원인이 풀릴 때까지 P 는 서 있고 아무것도 건너뛰지 않습니다.
|
|
97
|
+
- 오류는 read 에 `ILException` 으로 옵니다. 이번 호출에 줄 레코드가 있으면 그것을 돌려주고 **다음** read 가 올리며, 없으면
|
|
98
|
+
곧바로 올립니다. 되감았으므로 그 뒤 read 도 같은 오류입니다 - zstd 모듈을 설치하거나 `seek_to_offset` 으로 그 배치를 넘기면
|
|
99
|
+
풀립니다(그 파티션을 seek 하면 미뤄 둔 오류도 지웁니다). 다른 파티션이 바빠도 오류가 묻히지 않습니다(레코드를 돌려준 다음
|
|
100
|
+
호출은 늘 오류입니다).
|
|
101
|
+
- 분류: zstd 모듈 없음 / 모르는 코덱은 설치·설정 문제라 `MIMQC_USER_FAULT` / `MIMQE_NOT_SUPPORTED`, 깨진 자료는 예전처럼
|
|
102
|
+
`MIMQC_FATAL_ERROR` / `MIMQE_INTERNAL_ERROR` 입니다. 사유 문구(파티션, baseOffset, 되감은 자리 또는 되감기 거절 사유)는
|
|
103
|
+
`get_report_msg()` 와 원인(`__cause__`)에 있습니다.
|
|
104
|
+
- 되감기 seek 가 거절되면(재배정 뒤 이 멤버 것이 아님 4161, 수동 배정 밖) 그대로 둡니다 - 새 주인이 커밋된 자리부터 다시
|
|
105
|
+
읽습니다. 오류는 같게 올리고 문구에 거절 사유를 적습니다.
|
|
106
|
+
- `COMMIT_IMMEDIATE` 는 엔진이 읽는 순간 커밋했지만 되감으므로 이 멤버가 그 배치를 다시 받습니다(그 배치만 at-least-once).
|
|
107
|
+
다시 받기 전에 멤버가 끝나면 그 배치는 다시 오지 않을 수 있습니다(IMMEDIATE 는 at-most-once).
|
|
108
|
+
- `listen()` 은 받은 레코드를 콜백한 뒤 `on_error` 로 알리고 멈춥니다(요청을 되풀이하며 돌지 않습니다). 패턴 구독은 그 토픽을
|
|
109
|
+
떼어 내지 않고 그 파티션만 되감으며, 같은 응답의 다른 토픽 레코드는 그대로 줍니다.
|
|
110
|
+
- 패턴 구독 `read_batch` 는 요청 실패(요청 전체 거절 / 통신 실패)에서도 이 호출에서 이미 모은 레코드를 돌려주고 오류는 다음
|
|
111
|
+
호출이 올립니다(예전 판은 모은 레코드를 버렸는데 AUTO 커밋 좌표는 이미 병합돼 있어 앱이 못 본 레코드가 커밋됐습니다).
|
|
112
|
+
- **옛 클라이언트**: 엔진은 배치 read 에 저장한 바이트를 그대로 보내므로, 압축 토픽을 소비하는 구독자는 **0.8.0 / 자바 v2.4.0
|
|
113
|
+
이상**이어야 합니다. 압축을 켜기 전에 그 토픽의 소비자를 먼저 올리세요. 옛 판 중 압축 토픽을 읽는 것은 엔진 단건 read 를
|
|
114
|
+
쓰는 판뿐입니다 - 파이썬 0.6.x 의 `read()`, 자바 v2.2.x 의 prefetch 를 끈 `read()`(엔진이 풀어서 줍니다). 파이썬 0.7.x 와
|
|
115
|
+
자바 v2.3.x 는 `read()` 도 배치 read 라서 압축 배치에서 형식 오류(`MIMQC_FATAL_ERROR (MIMQE_INTERNAL_ERROR)`, 원인
|
|
116
|
+
`truncated topic batch ...`)로 멈춥니다 - 조용히 틀린 레코드를 주지는 않습니다(pv2test 엔진 7.0.1.3419 실측).
|
|
117
|
+
- **NIO 커넥터 송신은 기한 하나를 씁니다**(`TCPConnectorNIO`, `set_conn` 으로 고른 경우만): 송신 한 번 전체가 접속 대기 시간
|
|
118
|
+
(최소 1 s)을 기한으로 씁니다. 기한이 지나면 `MIMQE_SESSION_TIMEOUT`(`write waiting time has exceeded the time limit : [Nms]
|
|
119
|
+
/ written : [n / 전체]`)을 내고 연결을 닫습니다. 전에는 보낼 자리가 날 때까지 기다릴 때마다 한도를 새로 주어, 상대가
|
|
120
|
+
조금씩만 읽으면 송신이 끝없이 길어졌습니다. 기본(블로킹) 커넥터는 그대로입니다.
|
|
121
|
+
- **교차 검증 도구** `tests/cmpr_vectors.py`: `write <dir>` 는 실제 producer 인코딩 경로로 만든 배치(`py-none` / `py-gzip` /
|
|
122
|
+
`py-zstd` `.batch`)와 기대 레코드(`.json`)를 쓰고, `check <dir>` 는 디렉터리의 모든 `*.batch` 를 실제 소비 파싱 경로로 풀어
|
|
123
|
+
짝 `.json` 과 대조합니다. 자바 쪽 같은 도구가 쓴 `java-*.batch` 도 함께 검사합니다.
|
|
124
|
+
|
|
125
|
+
## 0.7.4 변경 - 패턴 토픽 떼어 내기 = 그룹 탈퇴(LEAVE), seek 규칙 (자바 v2.3.5 와 같음)
|
|
126
|
+
|
|
127
|
+
- **엔진 요구**: 아래 새 동작은 **엔진 7.0.1.3417 이상**(CONSGRP-1)에서 나옵니다. 그보다 옛 엔진에서는 그룹 탈퇴 요청을 조용히
|
|
128
|
+
건너뛰고(예전처럼 패턴 구독을 닫을 때까지 멤버로 남습니다), 파티션을 생략한 `seek_to_offset()` 은 0번 파티션을 되감습니다.
|
|
129
|
+
클라이언트는 엔진 판을 따로 검사하지 않습니다.
|
|
130
|
+
- **패턴 구독 - 떼어 낸 토픽은 그룹에서 빠집니다**: 재평가로 더 이상 매칭되지 않거나 read 중 오류로 떼어 낸 토픽은 그 (토픽, 구독)
|
|
131
|
+
그룹에서 이 세션을 뺍니다(`MIMQ_TOPIC_LEAVE` = 3401). 엔진이 곧바로 재배정하므로 같은 구독 이름의 다른 멤버가 그 파티션을
|
|
132
|
+
이어받고, 커서는 남습니다. 예전에는 연결을 닫지 않으니 패턴 구독을 닫을 때까지 멤버로 남아 그 파티션을 아무도 읽지 않았습니다.
|
|
133
|
+
그 토픽이 실린 다중 토픽 read 가 엔진에 걸려 있는 동안은 보내지 않고(엔진이 `MIMQE_READ_IN_PROGRESS` 로 거절합니다) 그 read 가
|
|
134
|
+
돌아온 직후 / 다음 read 전에 보냅니다 - 다른 스레드의 `refresh_now()` 는 걸린 read 를 기다리지 않고 돌아옵니다. best-effort 라
|
|
135
|
+
실패(구독 없음, 옛 엔진, 통신 실패)는 앱에 올리지 않습니다. 패턴 `close()` / `unsubscribe()` 와 단일 토픽 구독의 `close()` 는
|
|
136
|
+
그대로입니다(연결을 닫으면 엔진이 멤버를 뺍니다).
|
|
137
|
+
- **seek 는 이 멤버에게 배정된 파티션만**: 배정 밖 파티션이면 `ILOperationException`
|
|
138
|
+
(`MIMQE_TOPIC_REBALANCED (partition N is not owned by this member)`) 입니다. 재배정 통지가 아니라 거절이라 받아 둔 레코드는
|
|
139
|
+
그대로이고 다시 보내지 않습니다. 아직 read 하지 않은 구독은 그룹에 멤버가 하나도 없을 때만 seek 할 수 있습니다 - "subscribe →
|
|
140
|
+
seek → read" 는 혼자일 때만 되고, 다른 멤버가 있으면 첫 read 로 배정 통지를 받은 뒤(예: `set_rebalance_listener()` 콜백 안)
|
|
141
|
+
seek 하세요. `assign()` 구독은 배정 목록 밖 파티션이면 `(Partition : N not in manual assignment)` 사유로 거절됩니다. 관리 연결
|
|
142
|
+
(`ILAdminTopicSubscription`)의 seek 는 그룹에 멤버가 없을 때만 됩니다.
|
|
143
|
+
- **`seek_to_offset(offset)` - 파티션 생략은 엔진이 정합니다**: 0.7.3 은 생략하면 `;partition:0` 을 붙였습니다. 이제 `offset:N`
|
|
144
|
+
그대로 보냅니다. 새 엔진은 파티션이 하나인 토픽에서만 받고, 다중 파티션 토픽이면 `MIMQE_INVALID_ARGUMENT (Partition : REQUIRED,
|
|
145
|
+
Available : 0~N)` 로 거절합니다 - **다중 파티션 토픽에서는 `partition` 을 주세요.** 옛 엔진은 0번 파티션을 되감습니다. 성공하면
|
|
146
|
+
받아 둔 레코드는 0번 파티션 것만 버리고, 거절되면 아무것도 버리지 않습니다.
|
|
147
|
+
- **`seek_to_time(epoch_ms, partition=-1)`** (`seekToTime(epochMs, partition)`): 파티션을 줄 수 있습니다(`time:T;partition:P`,
|
|
148
|
+
받아 둔 레코드도 P 것만 버립니다). 새 엔진은 이 멤버에게 배정된 파티션만 옮기고(파티션을 주면 그 파티션만), 돌려주는 값은 옮긴
|
|
149
|
+
파티션 중 번호가 가장 작은 파티션의 결과 오프셋입니다(옮긴 것이 없으면 -1). 옛 엔진은 파티션을 무시하고 구독 전체를 옮기며
|
|
150
|
+
0번 파티션의 결과를 줍니다 - 옛 엔진에서는 파티션을 주지 마세요.
|
|
151
|
+
- **문서**: `ILAdminTopicSubscription.seek_to_offset()` 의 "`-1` 이면 전 파티션" 은 틀린 설명이었습니다(엔진은 0번 파티션만
|
|
152
|
+
되감았습니다).
|
|
153
|
+
- **NIO 커넥터(`TCPConnectorNIO`)도 송수신이 실패하면 연결을 닫습니다**(기본 커넥터와 같게): 전에는 큰 프레임을 받다가 시간이 다하면
|
|
154
|
+
나머지가 소켓에 남은 채 연결이 살아 있어 같은 연결의 다음 요청이 그 조각부터 읽었고(`invalid transmission delimiter`), 자동 확인
|
|
155
|
+
GET 이면 그 메시지를 잃었습니다. 상대가 프레임 중간에 끊으면 끝없이 돌던 것도 곧바로 `MIMQE_NIO_ERROR` 로 끝나고, 기한이 지났어도
|
|
156
|
+
소켓에 다 와 있는 프레임은 돌려주며, 보낼 자리가 없을 때(`BlockingIOError`) 곧바로 실패하던 송신은 기다렸다 이어 보냅니다. 기본
|
|
157
|
+
(블로킹) 커넥터에서도 프레임 중간에 시간이 다한 자동 확인 GET 의 메시지는 잃습니다(최대 한 번 전달) - 아주 큰 메시지는 GET 대기
|
|
158
|
+
시간 / 세션 시간 초과를 넉넉히 주거나 트랜잭션 GET(`auto_commit=False` 세션 + `commit()`)을 쓰세요.
|
|
159
|
+
|
|
57
160
|
## 0.7.3 변경 - 구독마다 전용 연결, 소비 프로토콜 v5, 발행 / 소비 CPU 절감 (자바 v2.3.4 와 같음)
|
|
58
161
|
|
|
59
162
|
- **엔진 요구**: 토픽 구독은 **엔진 7.0.1.3415 이상**(소비 프로토콜 v5)에서만 동작합니다. 그보다 옛 엔진은 요청 태그를
|
|
@@ -3,11 +3,112 @@
|
|
|
3
3
|
Python으로 작성된 ILink 클라이언트 API입니다.
|
|
4
4
|
|
|
5
5
|
## 시스템 요구 사항
|
|
6
|
-
시스템에
|
|
6
|
+
시스템에 Python 3.9 이상, pip가 설치되어 있어야 합니다. 필수 의존성은 없습니다(표준 라이브러리만 씁니다).
|
|
7
|
+
zstd 배치 압축을 쓰려면 `pip install "jetstream-api[zstd]"` 로 설치합니다(Python 3.14 이상은 표준 라이브러리로 됩니다).
|
|
7
8
|
|
|
8
9
|
## 설치 방법
|
|
9
10
|
pip install jetstream-api
|
|
10
11
|
|
|
12
|
+
## 0.8.0 변경 - 배치 압축 gzip / zstd (자바 v2.4.0 과 같음)
|
|
13
|
+
|
|
14
|
+
- **엔진 요구**: 압축 배치는 **엔진 7.0.1.3419 이상**(CMPR-1)에서만 받습니다. 그보다 옛 엔진은 압축 배치를 알아보지 못해
|
|
15
|
+
거절합니다. 기본값은 압축하지 않음(`none`)이라 설정하지 않으면 동작이 그대로입니다.
|
|
16
|
+
- **producer 설정** - 뜻과 기본값이 Kafka `compression.type` / `compression.gzip.level` / `compression.zstd.level` 과 같습니다:
|
|
17
|
+
- `compressionType("none" | "gzip" | "zstd")` (`compression_type`) - 기본 `"none"`. 소문자 그대로 씁니다(자바 / Kafka 와 같습니다).
|
|
18
|
+
`lz4` / `snappy` 는 지원하지 않습니다(`ValueError`).
|
|
19
|
+
- `compressionGzipLevel(n)` (`compression_gzip_level`) - `-1`(zlib 기본 레벨 6, 기본값) 또는 `1`~`9`.
|
|
20
|
+
- `compressionZstdLevel(n)` (`compression_zstd_level`) - `-131072`~`22`, 기본 `3`.
|
|
21
|
+
- 게터 `get_compression_type()` / `get_compression_gzip_level()` / `get_compression_zstd_level()`
|
|
22
|
+
(camelCase `getCompressionType()` / `getCompressionGzipLevel()` / `getCompressionZstdLevel()`).
|
|
23
|
+
- 범위 밖 값은 빌더에서 곧바로 `ValueError` 입니다. 직접 대입(`cfg.compressionType = "lz4"`)은 producer 생성 때
|
|
24
|
+
(`validate()`) 막습니다. 설정은 예전처럼 producer 생성 때 복사합니다.
|
|
25
|
+
- **zstd 는 선택 설치입니다**: `pip install "jetstream-api[zstd]"`(`zstandard>=0.22`). 파이썬 3.14 이상이면 표준 라이브러리
|
|
26
|
+
`compression.zstd` 를 먼저 쓰므로 설치하지 않아도 됩니다. gzip 은 표준 라이브러리(zlib)만 씁니다 - 필수 의존성은 여전히
|
|
27
|
+
없습니다. zstd 모듈이 없으면 `compressionType("zstd")` producer 생성이 연결하기 전에 `ILOperationException`
|
|
28
|
+
(`MIMQE_NOT_SUPPORTED (compressionType zstd: no zstd module - pip install ...)`)으로 멈추고, zstd 배치를 받은 구독 read 는
|
|
29
|
+
`ILException`(원인 `MIMQE_NOT_SUPPORTED (codec 4 zstd: no zstd module - pip install ...)`)입니다(자바와 같은 사유). 이 read
|
|
30
|
+
오류는 `MIMQC_USER_FAULT` / `MIMQE_NOT_SUPPORTED` 이고 그 배치에서 멈춥니다(아래 "풀지 못한 배치").
|
|
31
|
+
- **발행**: 파티션 배치를 보낼 때 레코드 열만 한 번 압축합니다(배치 헤더 52바이트는 그대로, `recordCount` 는 원래 건수, 배치
|
|
32
|
+
CRC 는 압축한 바이트가 대상). 재전송(연결 끊김, 순서 오류 재번호, PID 재발급)은 같은 압축 바이트에 헤더와 CRC 만 새로
|
|
33
|
+
씁니다. 압축해도 줄지 않는 배치(이미 압축된 자료, 무작위 바이트)는 압축하지 않고 보냅니다 - 한 토픽에 섞여도 됩니다.
|
|
34
|
+
`batchSize` 와 레코드 크기 검사(`maxRequestSize`, `maxFrameLength`, 토픽 세그먼트, `bufferMemory`)는 압축 **전** 크기로
|
|
35
|
+
셉니다(Kafka 와 같습니다). 봉투에 배치를 채울 때는 압축한 **뒤** 길이로 세므로 봉투 하나에 배치가 더 실립니다.
|
|
36
|
+
- **압축은 producer 의 I/O 스레드가 합니다**: `send()` 는 예전처럼 배치 버퍼에 붙이기만 합니다. zlib / zstd 는 압축하는 동안
|
|
37
|
+
GIL 을 놓으므로 `send()` 를 부르는 스레드와 겹쳐 돕니다. 대신 producer 하나의 압축은 한 스레드라, 느린 코덱(gzip 레벨 6 은
|
|
38
|
+
텍스트 배치에서 대략 60~70MB/s - 엔진 설계 문서의 측정)은 그 producer 의 발행 천장이 될 수 있습니다. zstd 나 낮은 gzip
|
|
39
|
+
레벨을 쓰거나 producer 를 늘리세요(한 토픽에 1~4개 안내는 그대로입니다).
|
|
40
|
+
- **소비**: 구독 read(배치 read 2042)와 패턴 구독(다중 토픽 read 2046)이 배치마다 코덱을 보고 풉니다. 한 토픽 / 한 응답에
|
|
41
|
+
무압축 / gzip / zstd 배치가 섞여도 됩니다. 오프셋과 커서 앞 레코드 빼기는 그대로입니다.
|
|
42
|
+
- **풀지 못한 배치는 그 자리에서 멈춥니다(stop-in-place)**: 풀지 못하는 배치 - 모르는 코덱 `MIMQE_TOPIC_UNSUPPORTED_CODEC (codec N)`,
|
|
43
|
+
zstd 모듈 없음, 깨짐 / 잘림 / 뒤에 남은 바이트 / 푼 크기 128MiB 초과 `MIMQE_TOPIC_CODEC_ERROR (gzip: ...)` / `(zstd: ...)`, 배치
|
|
44
|
+
CRC 불일치, 푼 레코드 수 / 길이가 헤더와 다름 - 를 만나도 응답 전체를 버리지 않습니다. 버리면 엔진 읽기 위치만 지나가 그 응답의
|
|
45
|
+
다른 배치까지 건너뛰고 다음 AUTO 커밋이 그 구간을 덮습니다(유실). Kafka 컨슈머처럼 그 자리에서 멈춥니다:
|
|
46
|
+
- 그 배치(파티션 P, baseOffset B) 앞에 푼 레코드와 같은 응답의 **다른 파티션** 레코드는 여느 때처럼 줍니다(`COMMIT_AUTO` 커밋은
|
|
47
|
+
앱에 준 것만). 같은 응답의 P 뒤 배치는 버리고, P 는 구독 연결로 B 로 되감습니다(`offset:B;partition:P` seek - 커서가 B 보다
|
|
48
|
+
뒤면 커서로). 원인이 풀릴 때까지 P 는 서 있고 아무것도 건너뛰지 않습니다.
|
|
49
|
+
- 오류는 read 에 `ILException` 으로 옵니다. 이번 호출에 줄 레코드가 있으면 그것을 돌려주고 **다음** read 가 올리며, 없으면
|
|
50
|
+
곧바로 올립니다. 되감았으므로 그 뒤 read 도 같은 오류입니다 - zstd 모듈을 설치하거나 `seek_to_offset` 으로 그 배치를 넘기면
|
|
51
|
+
풀립니다(그 파티션을 seek 하면 미뤄 둔 오류도 지웁니다). 다른 파티션이 바빠도 오류가 묻히지 않습니다(레코드를 돌려준 다음
|
|
52
|
+
호출은 늘 오류입니다).
|
|
53
|
+
- 분류: zstd 모듈 없음 / 모르는 코덱은 설치·설정 문제라 `MIMQC_USER_FAULT` / `MIMQE_NOT_SUPPORTED`, 깨진 자료는 예전처럼
|
|
54
|
+
`MIMQC_FATAL_ERROR` / `MIMQE_INTERNAL_ERROR` 입니다. 사유 문구(파티션, baseOffset, 되감은 자리 또는 되감기 거절 사유)는
|
|
55
|
+
`get_report_msg()` 와 원인(`__cause__`)에 있습니다.
|
|
56
|
+
- 되감기 seek 가 거절되면(재배정 뒤 이 멤버 것이 아님 4161, 수동 배정 밖) 그대로 둡니다 - 새 주인이 커밋된 자리부터 다시
|
|
57
|
+
읽습니다. 오류는 같게 올리고 문구에 거절 사유를 적습니다.
|
|
58
|
+
- `COMMIT_IMMEDIATE` 는 엔진이 읽는 순간 커밋했지만 되감으므로 이 멤버가 그 배치를 다시 받습니다(그 배치만 at-least-once).
|
|
59
|
+
다시 받기 전에 멤버가 끝나면 그 배치는 다시 오지 않을 수 있습니다(IMMEDIATE 는 at-most-once).
|
|
60
|
+
- `listen()` 은 받은 레코드를 콜백한 뒤 `on_error` 로 알리고 멈춥니다(요청을 되풀이하며 돌지 않습니다). 패턴 구독은 그 토픽을
|
|
61
|
+
떼어 내지 않고 그 파티션만 되감으며, 같은 응답의 다른 토픽 레코드는 그대로 줍니다.
|
|
62
|
+
- 패턴 구독 `read_batch` 는 요청 실패(요청 전체 거절 / 통신 실패)에서도 이 호출에서 이미 모은 레코드를 돌려주고 오류는 다음
|
|
63
|
+
호출이 올립니다(예전 판은 모은 레코드를 버렸는데 AUTO 커밋 좌표는 이미 병합돼 있어 앱이 못 본 레코드가 커밋됐습니다).
|
|
64
|
+
- **옛 클라이언트**: 엔진은 배치 read 에 저장한 바이트를 그대로 보내므로, 압축 토픽을 소비하는 구독자는 **0.8.0 / 자바 v2.4.0
|
|
65
|
+
이상**이어야 합니다. 압축을 켜기 전에 그 토픽의 소비자를 먼저 올리세요. 옛 판 중 압축 토픽을 읽는 것은 엔진 단건 read 를
|
|
66
|
+
쓰는 판뿐입니다 - 파이썬 0.6.x 의 `read()`, 자바 v2.2.x 의 prefetch 를 끈 `read()`(엔진이 풀어서 줍니다). 파이썬 0.7.x 와
|
|
67
|
+
자바 v2.3.x 는 `read()` 도 배치 read 라서 압축 배치에서 형식 오류(`MIMQC_FATAL_ERROR (MIMQE_INTERNAL_ERROR)`, 원인
|
|
68
|
+
`truncated topic batch ...`)로 멈춥니다 - 조용히 틀린 레코드를 주지는 않습니다(pv2test 엔진 7.0.1.3419 실측).
|
|
69
|
+
- **NIO 커넥터 송신은 기한 하나를 씁니다**(`TCPConnectorNIO`, `set_conn` 으로 고른 경우만): 송신 한 번 전체가 접속 대기 시간
|
|
70
|
+
(최소 1 s)을 기한으로 씁니다. 기한이 지나면 `MIMQE_SESSION_TIMEOUT`(`write waiting time has exceeded the time limit : [Nms]
|
|
71
|
+
/ written : [n / 전체]`)을 내고 연결을 닫습니다. 전에는 보낼 자리가 날 때까지 기다릴 때마다 한도를 새로 주어, 상대가
|
|
72
|
+
조금씩만 읽으면 송신이 끝없이 길어졌습니다. 기본(블로킹) 커넥터는 그대로입니다.
|
|
73
|
+
- **교차 검증 도구** `tests/cmpr_vectors.py`: `write <dir>` 는 실제 producer 인코딩 경로로 만든 배치(`py-none` / `py-gzip` /
|
|
74
|
+
`py-zstd` `.batch`)와 기대 레코드(`.json`)를 쓰고, `check <dir>` 는 디렉터리의 모든 `*.batch` 를 실제 소비 파싱 경로로 풀어
|
|
75
|
+
짝 `.json` 과 대조합니다. 자바 쪽 같은 도구가 쓴 `java-*.batch` 도 함께 검사합니다.
|
|
76
|
+
|
|
77
|
+
## 0.7.4 변경 - 패턴 토픽 떼어 내기 = 그룹 탈퇴(LEAVE), seek 규칙 (자바 v2.3.5 와 같음)
|
|
78
|
+
|
|
79
|
+
- **엔진 요구**: 아래 새 동작은 **엔진 7.0.1.3417 이상**(CONSGRP-1)에서 나옵니다. 그보다 옛 엔진에서는 그룹 탈퇴 요청을 조용히
|
|
80
|
+
건너뛰고(예전처럼 패턴 구독을 닫을 때까지 멤버로 남습니다), 파티션을 생략한 `seek_to_offset()` 은 0번 파티션을 되감습니다.
|
|
81
|
+
클라이언트는 엔진 판을 따로 검사하지 않습니다.
|
|
82
|
+
- **패턴 구독 - 떼어 낸 토픽은 그룹에서 빠집니다**: 재평가로 더 이상 매칭되지 않거나 read 중 오류로 떼어 낸 토픽은 그 (토픽, 구독)
|
|
83
|
+
그룹에서 이 세션을 뺍니다(`MIMQ_TOPIC_LEAVE` = 3401). 엔진이 곧바로 재배정하므로 같은 구독 이름의 다른 멤버가 그 파티션을
|
|
84
|
+
이어받고, 커서는 남습니다. 예전에는 연결을 닫지 않으니 패턴 구독을 닫을 때까지 멤버로 남아 그 파티션을 아무도 읽지 않았습니다.
|
|
85
|
+
그 토픽이 실린 다중 토픽 read 가 엔진에 걸려 있는 동안은 보내지 않고(엔진이 `MIMQE_READ_IN_PROGRESS` 로 거절합니다) 그 read 가
|
|
86
|
+
돌아온 직후 / 다음 read 전에 보냅니다 - 다른 스레드의 `refresh_now()` 는 걸린 read 를 기다리지 않고 돌아옵니다. best-effort 라
|
|
87
|
+
실패(구독 없음, 옛 엔진, 통신 실패)는 앱에 올리지 않습니다. 패턴 `close()` / `unsubscribe()` 와 단일 토픽 구독의 `close()` 는
|
|
88
|
+
그대로입니다(연결을 닫으면 엔진이 멤버를 뺍니다).
|
|
89
|
+
- **seek 는 이 멤버에게 배정된 파티션만**: 배정 밖 파티션이면 `ILOperationException`
|
|
90
|
+
(`MIMQE_TOPIC_REBALANCED (partition N is not owned by this member)`) 입니다. 재배정 통지가 아니라 거절이라 받아 둔 레코드는
|
|
91
|
+
그대로이고 다시 보내지 않습니다. 아직 read 하지 않은 구독은 그룹에 멤버가 하나도 없을 때만 seek 할 수 있습니다 - "subscribe →
|
|
92
|
+
seek → read" 는 혼자일 때만 되고, 다른 멤버가 있으면 첫 read 로 배정 통지를 받은 뒤(예: `set_rebalance_listener()` 콜백 안)
|
|
93
|
+
seek 하세요. `assign()` 구독은 배정 목록 밖 파티션이면 `(Partition : N not in manual assignment)` 사유로 거절됩니다. 관리 연결
|
|
94
|
+
(`ILAdminTopicSubscription`)의 seek 는 그룹에 멤버가 없을 때만 됩니다.
|
|
95
|
+
- **`seek_to_offset(offset)` - 파티션 생략은 엔진이 정합니다**: 0.7.3 은 생략하면 `;partition:0` 을 붙였습니다. 이제 `offset:N`
|
|
96
|
+
그대로 보냅니다. 새 엔진은 파티션이 하나인 토픽에서만 받고, 다중 파티션 토픽이면 `MIMQE_INVALID_ARGUMENT (Partition : REQUIRED,
|
|
97
|
+
Available : 0~N)` 로 거절합니다 - **다중 파티션 토픽에서는 `partition` 을 주세요.** 옛 엔진은 0번 파티션을 되감습니다. 성공하면
|
|
98
|
+
받아 둔 레코드는 0번 파티션 것만 버리고, 거절되면 아무것도 버리지 않습니다.
|
|
99
|
+
- **`seek_to_time(epoch_ms, partition=-1)`** (`seekToTime(epochMs, partition)`): 파티션을 줄 수 있습니다(`time:T;partition:P`,
|
|
100
|
+
받아 둔 레코드도 P 것만 버립니다). 새 엔진은 이 멤버에게 배정된 파티션만 옮기고(파티션을 주면 그 파티션만), 돌려주는 값은 옮긴
|
|
101
|
+
파티션 중 번호가 가장 작은 파티션의 결과 오프셋입니다(옮긴 것이 없으면 -1). 옛 엔진은 파티션을 무시하고 구독 전체를 옮기며
|
|
102
|
+
0번 파티션의 결과를 줍니다 - 옛 엔진에서는 파티션을 주지 마세요.
|
|
103
|
+
- **문서**: `ILAdminTopicSubscription.seek_to_offset()` 의 "`-1` 이면 전 파티션" 은 틀린 설명이었습니다(엔진은 0번 파티션만
|
|
104
|
+
되감았습니다).
|
|
105
|
+
- **NIO 커넥터(`TCPConnectorNIO`)도 송수신이 실패하면 연결을 닫습니다**(기본 커넥터와 같게): 전에는 큰 프레임을 받다가 시간이 다하면
|
|
106
|
+
나머지가 소켓에 남은 채 연결이 살아 있어 같은 연결의 다음 요청이 그 조각부터 읽었고(`invalid transmission delimiter`), 자동 확인
|
|
107
|
+
GET 이면 그 메시지를 잃었습니다. 상대가 프레임 중간에 끊으면 끝없이 돌던 것도 곧바로 `MIMQE_NIO_ERROR` 로 끝나고, 기한이 지났어도
|
|
108
|
+
소켓에 다 와 있는 프레임은 돌려주며, 보낼 자리가 없을 때(`BlockingIOError`) 곧바로 실패하던 송신은 기다렸다 이어 보냅니다. 기본
|
|
109
|
+
(블로킹) 커넥터에서도 프레임 중간에 시간이 다한 자동 확인 GET 의 메시지는 잃습니다(최대 한 번 전달) - 아주 큰 메시지는 GET 대기
|
|
110
|
+
시간 / 세션 시간 초과를 넉넉히 주거나 트랜잭션 GET(`auto_commit=False` 세션 + `commit()`)을 쓰세요.
|
|
111
|
+
|
|
11
112
|
## 0.7.3 변경 - 구독마다 전용 연결, 소비 프로토콜 v5, 발행 / 소비 CPU 절감 (자바 v2.3.4 와 같음)
|
|
12
113
|
|
|
13
114
|
- **엔진 요구**: 토픽 구독은 **엔진 7.0.1.3415 이상**(소비 프로토콜 v5)에서만 동작합니다. 그보다 옛 엔진은 요청 태그를
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""배치 압축 코덱(0.8.0). **내부 모듈이다** - 발행과 소비가 함께 쓴다.
|
|
2
|
+
|
|
3
|
+
발행 v2 파티션 배치(= 저장 v4 배치)의 레코드 열을 압축하고 푼다. 와이어(엔진 CMPR-1):
|
|
4
|
+
|
|
5
|
+
- 배치 헤더 52바이트는 압축하지 않는다. 레코드 열 ``[52, batchLength)`` 만 압축한다.
|
|
6
|
+
- 헤더 ``attributes`` 의 하위 3비트가 코덱이다: 0 없음 / 1 gzip / 4 zstd. 나머지 비트는 0 이다.
|
|
7
|
+
- ``recordCount`` 는 원래 레코드 수, ``batchLength`` 는 압축한 뒤 길이(52 + 압축 길이)이고, 배치 CRC 는 압축한
|
|
8
|
+
바이트 ``[28, batchLength)`` 가 대상이다(압축 -> 헤더 -> CRC 순서).
|
|
9
|
+
- gzip 은 gzip 틀(RFC 1952) 멤버 하나이고 뒤에 남는 바이트가 없어야 한다. zstd 는 표준 프레임이고 이어 붙인
|
|
10
|
+
여러 프레임도 받는다. 푼 크기 상한은 128MiB 다.
|
|
11
|
+
|
|
12
|
+
zstd 모듈은 필수 의존성이 아니다. 처음 쓸 때 표준 라이브러리 ``compression.zstd``(파이썬 3.14+)를 먼저, 없으면
|
|
13
|
+
``zstandard`` 패키지를 찾는다. 둘 다 없으면 zstd 를 고른 producer 생성과 zstd 배치 수신이 설치 안내를 담은 오류로
|
|
14
|
+
멈춘다. gzip 은 표준 라이브러리 zlib 만 쓴다.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
import zlib
|
|
21
|
+
|
|
22
|
+
from .exception import ILOperationException
|
|
23
|
+
|
|
24
|
+
#: 코덱 번호(배치 헤더 attributes 의 하위 3비트)
|
|
25
|
+
CODEC_NONE = 0
|
|
26
|
+
CODEC_GZIP = 1
|
|
27
|
+
CODEC_ZSTD = 4
|
|
28
|
+
#: attributes 에서 코덱을 뽑는 마스크
|
|
29
|
+
CODEC_MASK = 0x07
|
|
30
|
+
#: 설정 이름 -> 코덱 번호
|
|
31
|
+
CODECS = {"none": CODEC_NONE, "gzip": CODEC_GZIP, "zstd": CODEC_ZSTD}
|
|
32
|
+
#: 코덱 번호 -> 설정 이름
|
|
33
|
+
CODEC_NAMES = {v: k for k, v in CODECS.items()}
|
|
34
|
+
|
|
35
|
+
# 레벨의 기본값과 범위는 Kafka compression.gzip.level / compression.zstd.level 과 같다(kafka-clients 4.3.1 확인,
|
|
36
|
+
# 사용자 결정 2026-09-13): gzip -1(= zlib 기본 6) 또는 1~9, zstd 기본 3 / -131072(ZSTD_minCLevel)~22
|
|
37
|
+
GZIP_LEVEL_DEFAULT = -1
|
|
38
|
+
GZIP_LEVEL_MIN = 1
|
|
39
|
+
GZIP_LEVEL_MAX = 9
|
|
40
|
+
ZSTD_LEVEL_DEFAULT = 3
|
|
41
|
+
ZSTD_LEVEL_MIN = -131072
|
|
42
|
+
ZSTD_LEVEL_MAX = 22
|
|
43
|
+
|
|
44
|
+
#: 푼 레코드 열의 크기 상한(바이트). 엔진과 같은 128MiB 다. 시험이 낮출 수 있게 모듈 변수로 둔다(호출 때 읽는다)
|
|
45
|
+
MAX_RAW = 128 * 1024 * 1024
|
|
46
|
+
|
|
47
|
+
#: zstd 모듈이 없을 때 오류 문구에 싣는 설치 안내
|
|
48
|
+
ZSTD_HINT = 'pip install "jetstream-api[zstd]", or use Python 3.14+ (standard library compression.zstd)'
|
|
49
|
+
|
|
50
|
+
#: zstandard 백엔드가 한 번에 먹이는 압축 입력 크기(바이트). 그 API 는 출력 상한을 받지 않아 입력을 잘게 먹이며
|
|
51
|
+
#: 출력을 센다 - 넘침은 이 크기가 만들 수 있는 출력만큼으로 묶인다
|
|
52
|
+
_ZSTANDARD_FEED = 4096
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── 설정 검사 ────────────────────────────────────────────────────────────
|
|
56
|
+
def check_type(codec) -> str:
|
|
57
|
+
"""압축 코덱 이름을 검사한다.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
codec (str): ``"none"`` / ``"gzip"`` / ``"zstd"`` - 소문자 그대로다(``"GZIP"`` 은 받지 않는다).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
str: 받은 이름 그대로.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: 문자열이 아니거나 세 값 밖일 때.
|
|
67
|
+
"""
|
|
68
|
+
# 자바 ILTopicCodec.codecOf 와 Kafka compression.type 처럼 소문자 이름만 받는다(두 언어가 같은 설정을 같게 받는다)
|
|
69
|
+
if not isinstance(codec, str) or codec not in CODECS:
|
|
70
|
+
raise ValueError("compressionType must be none / gzip / zstd (got %r - lz4 and snappy are not supported)"
|
|
71
|
+
% (codec,))
|
|
72
|
+
return codec
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_gzip_level(level) -> int:
|
|
76
|
+
"""gzip 레벨을 검사한다.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
level (int): ``-1`` (zlib 기본 레벨 6) 또는 ``1`` ~ ``9``.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
int: 받은 레벨 그대로.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
ValueError: 정수가 아니거나 범위 밖일 때(``0`` 도 받지 않는다).
|
|
86
|
+
"""
|
|
87
|
+
if isinstance(level, bool) or not isinstance(level, int) or not (
|
|
88
|
+
level == GZIP_LEVEL_DEFAULT or GZIP_LEVEL_MIN <= level <= GZIP_LEVEL_MAX):
|
|
89
|
+
raise ValueError("compressionGzipLevel must be -1 (zlib default, 6) or %d..%d (got %r)"
|
|
90
|
+
% (GZIP_LEVEL_MIN, GZIP_LEVEL_MAX, level))
|
|
91
|
+
return level
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def check_zstd_level(level) -> int:
|
|
95
|
+
"""zstd 레벨을 검사한다.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
level (int): ``-131072`` ~ ``22``. 음수는 더 빠르고 덜 준다.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
int: 받은 레벨 그대로.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValueError: 정수가 아니거나 범위 밖일 때.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(level, bool) or not isinstance(level, int) or not (ZSTD_LEVEL_MIN <= level <= ZSTD_LEVEL_MAX):
|
|
107
|
+
raise ValueError("compressionZstdLevel must be %d..%d (got %r)" % (ZSTD_LEVEL_MIN, ZSTD_LEVEL_MAX, level))
|
|
108
|
+
return level
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── gzip ─────────────────────────────────────────────────────────────────
|
|
112
|
+
def gzip_compress(data, level: int = GZIP_LEVEL_DEFAULT) -> bytes:
|
|
113
|
+
"""gzip 틀(RFC 1952) 멤버 하나로 압축한다. 스레드마다 새 압축 객체를 쓰므로 어느 스레드에서 불러도 된다.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
data (bytes | bytearray | memoryview): 압축할 바이트.
|
|
117
|
+
level (int): ``-1`` (zlib 기본 레벨 6) 또는 ``1`` ~ ``9``.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
bytes: gzip 멤버 하나.
|
|
121
|
+
"""
|
|
122
|
+
c = zlib.compressobj(level, zlib.DEFLATED, 31) # wbits 16 + 15 = gzip 틀(엔진 inflateInit2(16 + MAX_WBITS))
|
|
123
|
+
return c.compress(data) + c.flush()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _gunzip(data, limit: int) -> bytes:
|
|
127
|
+
d = zlib.decompressobj(31) # gzip 틀만 받는다(zlib 틀 / raw deflate 는 오류)
|
|
128
|
+
try:
|
|
129
|
+
out = d.decompress(data, limit + 1)
|
|
130
|
+
except zlib.error as exc:
|
|
131
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (gzip: %s)" % exc) from None
|
|
132
|
+
if len(out) > limit:
|
|
133
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (gzip: decompressed size over %d bytes)" % limit)
|
|
134
|
+
if not d.eof:
|
|
135
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (gzip: truncated)")
|
|
136
|
+
if d.unused_data:
|
|
137
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (gzip: %d trailing bytes after the gzip member)" % len(d.unused_data))
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── zstd 백엔드 ──────────────────────────────────────────────────────────
|
|
142
|
+
class _StdlibZstd:
|
|
143
|
+
"""표준 라이브러리 ``compression.zstd``(파이썬 3.14+) 백엔드."""
|
|
144
|
+
|
|
145
|
+
name = "compression.zstd"
|
|
146
|
+
|
|
147
|
+
def __init__(self, mod) -> None:
|
|
148
|
+
self._m = mod
|
|
149
|
+
|
|
150
|
+
def compress(self, data, level: int) -> bytes:
|
|
151
|
+
"""프레임 하나로 압축한다(한 번에 끝내므로 원문 크기가 프레임에 실린다).
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
data (bytes | bytearray | memoryview): 압축할 바이트.
|
|
155
|
+
level (int): zstd 레벨(``-131072`` ~ ``22``).
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
bytes: zstd 프레임 하나.
|
|
159
|
+
"""
|
|
160
|
+
return self._m.compress(data, level=level)
|
|
161
|
+
|
|
162
|
+
def decompress(self, data, limit: int) -> bytes:
|
|
163
|
+
"""이어 붙인 프레임을 모두 푼다. 원문 크기가 없는 프레임도 된다.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
data (bytes | memoryview): zstd 프레임(들).
|
|
167
|
+
limit (int): 푼 크기 상한(바이트).
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
bytes: 푼 바이트.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
ValueError: 깨졌거나 잘렸거나 상한을 넘을 때(``MIMQE_TOPIC_CODEC_ERROR (zstd: ...)``).
|
|
174
|
+
"""
|
|
175
|
+
m = self._m
|
|
176
|
+
err = getattr(m, "ZstdError", Exception)
|
|
177
|
+
out = []
|
|
178
|
+
total = 0
|
|
179
|
+
rest = data
|
|
180
|
+
while True:
|
|
181
|
+
# ZstdDecompressor 하나는 프레임 하나만 푼다 - 남은 바이트(unused_data)는 다음 프레임으로 새 객체에 넘긴다
|
|
182
|
+
d = m.ZstdDecompressor()
|
|
183
|
+
try:
|
|
184
|
+
chunk = d.decompress(rest, max_length=limit - total + 1)
|
|
185
|
+
except err as exc:
|
|
186
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (zstd: %s)" % exc) from None
|
|
187
|
+
total += len(chunk)
|
|
188
|
+
if total > limit:
|
|
189
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (zstd: decompressed size over %d bytes)" % limit)
|
|
190
|
+
out.append(chunk)
|
|
191
|
+
if not d.eof:
|
|
192
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (zstd: truncated)")
|
|
193
|
+
rest = d.unused_data
|
|
194
|
+
if not rest:
|
|
195
|
+
return b"".join(out)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _ZstandardZstd:
|
|
199
|
+
"""``zstandard`` 패키지 백엔드(``pip install "jetstream-api[zstd]"``)."""
|
|
200
|
+
|
|
201
|
+
name = "zstandard"
|
|
202
|
+
|
|
203
|
+
def __init__(self, mod) -> None:
|
|
204
|
+
self._m = mod
|
|
205
|
+
# ZstdCompressor / ZstdDecompressor 는 스레드에 안전하지 않다 - 스레드마다 둔다
|
|
206
|
+
self._tls = threading.local()
|
|
207
|
+
|
|
208
|
+
def compress(self, data, level: int) -> bytes:
|
|
209
|
+
"""프레임 하나로 압축한다(원문 크기를 프레임에 싣는다).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
data (bytes | bytearray | memoryview): 압축할 바이트.
|
|
213
|
+
level (int): zstd 레벨(``-131072`` ~ ``22``).
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
bytes: zstd 프레임 하나.
|
|
217
|
+
"""
|
|
218
|
+
cache = getattr(self._tls, "comp", None)
|
|
219
|
+
if cache is None:
|
|
220
|
+
cache = self._tls.comp = {}
|
|
221
|
+
c = cache.get(level)
|
|
222
|
+
if c is None:
|
|
223
|
+
c = cache[level] = self._m.ZstdCompressor(level=level, write_content_size=True)
|
|
224
|
+
return c.compress(data)
|
|
225
|
+
|
|
226
|
+
def decompress(self, data, limit: int) -> bytes:
|
|
227
|
+
"""이어 붙인 프레임을 모두 푼다. 원문 크기가 없는 프레임도 된다.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
data (bytes | memoryview): zstd 프레임(들).
|
|
231
|
+
limit (int): 푼 크기 상한(바이트).
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
bytes: 푼 바이트.
|
|
235
|
+
|
|
236
|
+
Raises:
|
|
237
|
+
ValueError: 깨졌거나 잘렸거나 상한을 넘을 때(``MIMQE_TOPIC_CODEC_ERROR (zstd: ...)``).
|
|
238
|
+
"""
|
|
239
|
+
m = self._m
|
|
240
|
+
err = getattr(m, "ZstdError", Exception)
|
|
241
|
+
dctx = getattr(self._tls, "dctx", None)
|
|
242
|
+
if dctx is None:
|
|
243
|
+
dctx = self._tls.dctx = m.ZstdDecompressor()
|
|
244
|
+
mv = memoryview(data)
|
|
245
|
+
n = len(mv)
|
|
246
|
+
if n == 0:
|
|
247
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (zstd: truncated)")
|
|
248
|
+
out = []
|
|
249
|
+
total = 0
|
|
250
|
+
pos = 0
|
|
251
|
+
try:
|
|
252
|
+
while pos < n:
|
|
253
|
+
# 프레임마다: 원문 크기를 밝힌 프레임은 풀기 전에 상한을 본다(-1 = 밝히지 않음). 머리를 못 읽으면(잘림 /
|
|
254
|
+
# 프레임 아님) 여기서 판정하지 않고 아래 풀기가 잘림 / 깨짐으로 가린다
|
|
255
|
+
try:
|
|
256
|
+
size = m.frame_content_size(mv[pos:])
|
|
257
|
+
except err:
|
|
258
|
+
size = -1
|
|
259
|
+
if size > limit - total:
|
|
260
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (zstd: decompressed size over %d bytes)" % limit)
|
|
261
|
+
d = dctx.decompressobj()
|
|
262
|
+
while True:
|
|
263
|
+
piece = mv[pos:pos + _ZSTANDARD_FEED]
|
|
264
|
+
if not piece:
|
|
265
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (zstd: truncated)")
|
|
266
|
+
chunk = d.decompress(piece)
|
|
267
|
+
total += len(chunk)
|
|
268
|
+
if total > limit:
|
|
269
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (zstd: decompressed size over %d bytes)" % limit)
|
|
270
|
+
out.append(chunk)
|
|
271
|
+
if d.eof:
|
|
272
|
+
# 프레임이 끝났다 - 이번에 먹인 것 중 남은 것(unused_data)은 다음 프레임의 앞이다
|
|
273
|
+
pos += len(piece) - len(d.unused_data)
|
|
274
|
+
break
|
|
275
|
+
pos += len(piece)
|
|
276
|
+
except err as exc:
|
|
277
|
+
raise ValueError("MIMQE_TOPIC_CODEC_ERROR (zstd: %s)" % exc) from None
|
|
278
|
+
return b"".join(out)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_UNPROBED = object()
|
|
282
|
+
#: zstd 백엔드. _UNPROBED = 아직 찾지 않음, None = 없음. 시험이 가짜 백엔드로 바꿔 끼울 수 있다
|
|
283
|
+
_zstd_state = _UNPROBED
|
|
284
|
+
_probe_lock = threading.Lock()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _probe():
|
|
288
|
+
"""표준 라이브러리(3.14+) -> zstandard 순으로 찾는다. 없으면 None."""
|
|
289
|
+
try:
|
|
290
|
+
from compression import zstd as mod # type: ignore[import-not-found]
|
|
291
|
+
except ImportError:
|
|
292
|
+
mod = None
|
|
293
|
+
if mod is not None:
|
|
294
|
+
return _StdlibZstd(mod)
|
|
295
|
+
try:
|
|
296
|
+
import zstandard as mod2 # type: ignore[import-not-found]
|
|
297
|
+
except ImportError:
|
|
298
|
+
return None
|
|
299
|
+
return _ZstandardZstd(mod2)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def zstd_backend():
|
|
303
|
+
"""zstd 백엔드를 돌려준다. 처음 부를 때 찾는다.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
object | None: ``compress(data, level)`` / ``decompress(data, limit)`` 를 가진 백엔드. 없으면 None.
|
|
307
|
+
"""
|
|
308
|
+
global _zstd_state
|
|
309
|
+
s = _zstd_state
|
|
310
|
+
if s is _UNPROBED:
|
|
311
|
+
with _probe_lock:
|
|
312
|
+
if _zstd_state is _UNPROBED:
|
|
313
|
+
_zstd_state = _probe()
|
|
314
|
+
s = _zstd_state
|
|
315
|
+
return s
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ── 발행 / 소비 쪽 입구 ───────────────────────────────────────────────────
|
|
319
|
+
def compressor(codec: str, gzip_level: int, zstd_level: int):
|
|
320
|
+
"""producer 가 쓸 (코덱 번호, 압축 함수) 를 만든다. 압축 함수는 레코드 열 바이트를 받아 압축한 bytes 를 돌려준다.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
codec (str): ``"none"`` / ``"gzip"`` / ``"zstd"``.
|
|
324
|
+
gzip_level (int): gzip 레벨(``-1`` 또는 ``1`` ~ ``9``).
|
|
325
|
+
zstd_level (int): zstd 레벨(``-131072`` ~ ``22``).
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
tuple: ``(코덱 번호, 압축 함수)``. ``"none"`` 이면 ``(0, None)``.
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
ValueError: 코덱 이름이나 레벨이 틀렸을 때.
|
|
332
|
+
ILOperationException: ``"zstd"`` 인데 zstd 모듈이 없을 때 - ``MIMQE_NOT_SUPPORTED (...)`` 에 설치 안내를 싣는다.
|
|
333
|
+
"""
|
|
334
|
+
c = CODECS[check_type(codec)]
|
|
335
|
+
if c == CODEC_NONE:
|
|
336
|
+
return CODEC_NONE, None
|
|
337
|
+
if c == CODEC_GZIP:
|
|
338
|
+
glv = check_gzip_level(gzip_level)
|
|
339
|
+
return CODEC_GZIP, lambda data: gzip_compress(data, glv)
|
|
340
|
+
zlv = check_zstd_level(zstd_level)
|
|
341
|
+
be = zstd_backend()
|
|
342
|
+
if be is None:
|
|
343
|
+
raise ILOperationException("MIMQE_NOT_SUPPORTED (compressionType zstd: no zstd module - %s)" % ZSTD_HINT)
|
|
344
|
+
return CODEC_ZSTD, lambda data: be.compress(data, zlv)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def decompress(codec: int, data) -> bytes:
|
|
348
|
+
"""배치 하나의 레코드 열을 푼다. 푼 크기는 :data:`MAX_RAW` 까지다.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
codec (int): 배치 헤더 attributes 의 하위 3비트(``1`` gzip / ``4`` zstd).
|
|
352
|
+
data (bytes | memoryview): 압축된 레코드 열(``[52, batchLength)``).
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
bytes: 푼 레코드 열.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
ValueError: 모르는 코덱(``MIMQE_TOPIC_UNSUPPORTED_CODEC (codec N)``), zstd 모듈 없음(``MIMQE_NOT_SUPPORTED``,
|
|
359
|
+
설치 안내를 싣는다),
|
|
360
|
+
깨졌거나 잘렸거나 뒤에 남는 바이트가 있거나 상한을 넘을 때(``MIMQE_TOPIC_CODEC_ERROR (...)``).
|
|
361
|
+
"""
|
|
362
|
+
limit = MAX_RAW
|
|
363
|
+
if codec == CODEC_GZIP:
|
|
364
|
+
return _gunzip(data, limit)
|
|
365
|
+
if codec == CODEC_ZSTD:
|
|
366
|
+
be = zstd_backend()
|
|
367
|
+
if be is None:
|
|
368
|
+
raise ValueError("MIMQE_NOT_SUPPORTED (codec 4 zstd: no zstd module - %s)" % ZSTD_HINT)
|
|
369
|
+
return be.decompress(data, limit)
|
|
370
|
+
raise ValueError("MIMQE_TOPIC_UNSUPPORTED_CODEC (codec %d)" % codec)
|