Metadata-Version: 2.4
Name: jetstream-api
Version: 0.7.2
Summary: Python client API for iLink M.O.M. (Message Oriented Middleware)
Author: snowjeans
License: Boost Software License - Version 1.0 - August 17th, 2003
        
        Permission is hereby granted, free of charge, to any person or organization
        obtaining a copy of the software and accompanying documentation covered by
        this license (the "Software") to use, reproduce, display, distribute,
        execute, and transmit the Software, and to prepare derivative works of the
        Software, and to permit third-parties to whom the Software is furnished to
        do so, all subject to the following:
        
        The copyright notices in the Software and this entire statement, including
        the above license grant, this restriction and the following disclaimer,
        must be included in all copies of the Software, in whole or in part, and
        all derivative works of the Software, unless such copies or derivative
        works are solely in the form of machine-executable object code generated by
        a source language processor.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
        SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
        FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
        ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
        DEALINGS IN THE SOFTWARE.
        
Keywords: ilink,mom,messaging,middleware,queue,jetstream
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: License :: OSI Approved :: Boost Software License 1.0 (BSL-1.0)
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: System :: Networking
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE.txt
Provides-Extra: test
Requires-Dist: docstring_parser>=0.16; extra == "test"
Dynamic: license-file

﻿# jetstream-api

Python으로 작성된 ILink 클라이언트 API입니다.

## 시스템 요구 사항
시스템에 python 13 이상, pip가 설치되어 있어야 합니다.

## 설치 방법
pip install jetstream-api

## 0.7.2 변경 - batchSize 기본값 262144 로 되돌림, 발행 경로 CPU 절감 (자바 v2.3.2 와 같음)

- `batchSize` 기본값을 **262144** 로 되돌렸습니다. 0.7.1 의 16384 에서 파이썬 클라는 처리량이 24~35% 낮았습니다(GIL 이
  천장이라 배치마다 드는 비용이 그대로 처리량에서 빠집니다). 멱등 기본 켜짐은 그대로입니다.
- 발행 경로 CPU 를 줄였습니다(API 는 그대로입니다). 레코드 future 가 배치 결과 하나를 함께 가리키고(완결은 배치당 한 번),
  키 -> 파티션 결과를 캐시하고, `send()` 가 설정값을 레코드마다 다시 읽지 않고, I/O 스레드를 깨우는 신호를 합치고,
  ACK 하나의 배치 완결을 잠금 한 번에 묶습니다.
- producer 는 만들 때 설정 객체를 **복사**합니다(자바 v2.3.2 도 같고, Kafka 와 같습니다). 만든 뒤 넘긴 객체를 바꿔도 그
  producer 에는 반영되지 않습니다 - 설정을 바꾸려면 producer 를 새로 만드십시오.
- 문서: 멱등이 거르는 것은 producer 안의 재전송뿐입니다 - 앱이 `send` 를 다시 부르면 새 레코드로 적재됩니다.

## 0.7.1 변경 - producer 기본값: 멱등 켜짐, batchSize 16384 (자바 v2.3.1 과 같음)

- `enableIdempotence` 기본값이 **켜짐**입니다. 멱등을 직접 정하지 않았으면 `acks(0)` 이나 `maxInFlight` 가 5 를 넘을 때
  멱등이 저절로 꺼집니다. `enableIdempotence(True)` 를 명시하고 그 둘과 함께 쓰면 전처럼 생성 때 `ValueError` 입니다.
- 멱등이면 토픽을 비우거나(FLUSH) epoch 가 바뀐 순간 걸려 있던 배치를 다시 보내지 않고 실패로 올립니다
  (`MIMQE_TOPIC_FLUSHED` / `MIMQE_TOPIC_EPOCH_MISMATCH`). 0.7.0 기본처럼 조용히 다시 보내려면 `enableIdempotence(False)` 를
  명시하십시오.
- `batchSize` 기본값이 **16384** 입니다(파티션 배치 하나의 상한). 0.7.0 은 262144 였습니다 - 처리량이 모자라면
  `batchSize(262144)` 처럼 늘리십시오.
- producer 수 안내: 한 토픽의 producer(연결)는 1~4개면 충분합니다(엔진 7.0.1.3407 실측).

## 0.7.0 변경 - 발행 v2: producer 하나 = 토픽 하나, 파티션별 배치, 봉투 겹쳐 보내기 (엔진 v2 필요)

발행 경로를 엔진의 새 발행 형식(v2)으로 바꿨습니다. **엔진도 v2 판이어야 합니다** - 옛 엔진에는 발행이 되지 않고
(`MIMQE_NOT_SUPPORTED`), 옛 클라이언트(0.6.x)는 v2 엔진에 발행할 수 없습니다. 둘을 함께 올리십시오.

- **producer 하나 = 토픽 하나 = 연결 하나.** `qmgr.create_producer("ORDER.EVENT", config)` 로 만듭니다. 생성할 때 토픽의
  파티션 수를 받아 두므로 없는 토픽이면 생성 단계에서 `MIMQE_OBJECT_NOT_FOUND` 로 실패합니다. 토픽이 여러 개면 토픽마다
  producer 를 만듭니다. 한 토픽의 producer(연결)는 1~4개면 충분합니다 - 엔진 7.0.1.3407 실측에서 8개로 늘리면 어느 크기든
  오히려 줄었습니다. 큰 레코드(1KB 급)는 1개로도 디스크가 먼저 한계에 닿고, 작은 레코드(100B 급)는 4개 근처가 최대입니다.
- 레코드 토픽이 producer 토픽과 다르면 `send()` 가 `MIMQE_INVALID_ARGUMENT` 로 거부합니다.
- 옛 `create_producer(config)` / `ILTopicProducer(host, port, name, config)` 모양으로 부르면 `TypeError` 가 납니다.
- 배치는 **파티션마다** 모입니다. `batchSize`(기본 **262144**, 자바와 같음)는 파티션 배치 하나의 상한입니다. 보낼 때는
  준비된 파티션 배치들을 봉투 하나에 `maxRequestSize` 까지 싣고, 봉투를 `maxInFlight`(기본 5) 개까지 겹쳐 보냅니다.
  완결은 I/O 스레드가 하므로 `lingerMs=0` 이어도 `send()` 가 돌아온 시점에 끝나 있다는 보장은 없습니다 - 동기 발행은
  `send(rec).get()` 입니다. `send(rec, callback)` 으로 콜백(`callback(metadata, exc)`)을 걸 수 있습니다.
- 멱등 발행은 파티션 배치 단위로 번호를 매깁니다. 멱등이면 `maxInFlight` 는 5 이하여야 합니다.
- 버퍼(`bufferMemory`)가 차면 `send()` 가 `maxBlockMs` 만큼 기다렸다가 `MIMQE_QUEUE_FULL (buffer memory full ...)` 로
  거부합니다(전에는 그 자리에서 비웠습니다). 부하 문제이므로 다시 시도할 수 있습니다.
- 크기 상한: 레코드 하나가 혼자 실린 봉투가 `maxRequestSize` 이하, key 와 속성은 각각 99,999바이트까지입니다. 넘으면
  `send()` 가 `MIMQE_MSG_SIZE_OVER (...)` 로 거부합니다. 배치는 토픽 세그먼트 크기에도 맞춰 끊습니다. 봉투 건수 상한(10만 건)은
  없어졌습니다.
- 멱등 발행에서 리더가 바뀌는 순간, 팔로워가 거절한 배치 뒤의 배치가 먼저 적재되면 앞 배치는 번호가 틈으로 남습니다. 이 배치는
  `MIMQE_OUT_OF_ORDER_SEQUENCE (sequence gap ...)` 로 **실패**합니다(적재되지 않았습니다). 전에는 이 경우 성공으로 보고되고
  레코드가 사라질 수 있었습니다.
- 소비자 배치 읽기가 엔진 저장 v4 응답(2042)을 받습니다. 엔진은 배치를 쪼개지 않아 요청한 개수보다 많이 줄 수 있습니다.
  `read_batch(max, t)` 는 그래도 **max 이하**를 돌려주고, 넘친 레코드는 구독이 들고 있다가 다음 `read` / `read_batch` 가
  먼저 줍니다. `COMMIT_AUTO` / `COMMIT_MANUAL` 은 앱에 건넨 레코드까지만 커밋하므로 들고 있던 레코드는 끊기면 다시 옵니다.
  `COMMIT_IMMEDIATE` 는 엔진이 읽는 순간 배치 끝까지 커밋하므로 들고 있던 레코드도 이미 커밋된 상태라, 앱에 건네기 전에
  끊기면 다시 오지 않습니다(at-most-once). `seek` 는 들고 있던 레코드를 버립니다. 옛 배치 응답(2024)은 받지 않습니다 -
  배치 읽기에는 저장 v4 엔진이 필요합니다.
- **없어진 API**: `ILTopicFrameMsg` 의 발행 조립(`createPublish`, `buildBatchPublishEnvelope`, `patchPidSeq`,
  `setAckMode`, `setPartition`, `setPidSeq`, `PUBLISH_*_OFFSET`)과 옛 응답 파싱(`getCorrId`, `getResultCode`, `getReason`,
  `getResultPartition` / `Offset` / `Timestamp` / `Reason`), 상수 `ILC.MIMQ_PUBLISH_MESSAGE` / `MIMQ_PUBLISH_ACK_MESSAGE` /
  `MIMQ_BATCH_PUBLISH_MESSAGE` / `MIMQ_BATCH_PUBLISH_ACK_MESSAGE`. `ILTopicFrameMsg` 는 구독 전달 수신 전용이 됐습니다.
- 추가: `ILTopicProducer.get_topic()` / `get_partition_count()`, `ILC.MIMQ_TOPIC_PUBLISH_V2_MESSAGE`(2040) /
  `MIMQ_TOPIC_PUBLISH_V2_ACK_MESSAGE`(2041), 결과 코드 `ILC.MIMQ_TOPIC_PUBLISH_DUPLICATE`(4164) ~ `FOLLOWER_NODE`(4172),
  `ILC.MIMQ_TOPIC_BATCH_V4_MESSAGE`(2042)와 수신 전용 `ILTopicBatchV4Msg`.

## 0.6.11 변경 - 메시지 크기 상한(기본 128MiB), 재시도 보호, 클러스터 편의 개선

- **메시지(프레임) 크기 상한의 기본값이 128MiB 입니다**(엔진 `Runtime@MaxFrameLength` 기본과 같습니다). 넘는 메시지는 **보내기 전에** `MIMQE_MSG_SIZE_OVER` 로 막고 연결은 그대로 둡니다.
  - 엔진은 서버의 상한을 알려 주지 않습니다. 서버에서 상한을 올렸다면 클라이언트도 같이 올리십시오 - 연결은 `setMaxFrameLength(n)`(`ILQmgr` / `ILAdminService`), producer 는 `ILProducerConfig.maxFrameLength(n)` 입니다. 범위는 1,048,576 ~ 999,999,999 입니다.
  - producer 레코드 한 건의 실제 상한은 `maxRequestSize` 와 `maxFrameLength - 25`(헤더) 중 작은 쪽입니다.
- **재시도 보호**: 1MiB 를 넘는 레코드를 보낸 직후 응답 없이 연결이 **연속 2번** 끊기면, producer 는 재시도를 멈추고 그 레코드를 `MIMQE_MSG_SIZE_OVER` 로 실패시킵니다. 서버 상한이 클라이언트 설정보다 작을 때 같은 큰 레코드를 시한이 다할 때까지 되풀이해 보내지 않게 하려는 것입니다. 큰 레코드는 따로 보내므로 옆 레코드는 영향을 받지 않습니다.
- `ILClusterNode("QM1", "10.0.0.12", 21001, 27091)` 처럼 자바와 같은 순서로 만들 수 있습니다(`ILClusterProperty("C1", 27091)` 도 같습니다).
- `getAuthorityHolderType()` 은 권한 보유자를 `"MAIN"` / `"SUB"` / `""` 로 줍니다. `getAuthorityHolder()` 는 선 코드(`"M"` / `"S"`)라 `clusterType` 과 바로 비교하면 늘 거짓입니다.

★ 엔진 7.0.1.3404 전에는 엔진 쪽 상한이 999,999,999 였습니다. 그런 엔진에 128MiB 를 넘는 메시지를 보내려면 위 설정을 올리십시오.

## 0.6.10 변경 - 클러스터 관리 API 를 판사/조정자 모델로 (클러스터는 엔진 7.0.1.3391 이상)

클러스터 하나는 관리 서비스 셋(판사 · MAIN · SUB)에 **각자 정의를 만듭니다.** 한 서비스에 만들면 나머지에 퍼지던 옛 방식은 엔진에서 없어졌습니다.

- 정의는 `ILClusterProperty.judge(name, main, sub, ilcc_port)` 와 `ILClusterProperty.coordinator(name, "MAIN" 또는 "SUB", ilcc_port)` 로 만들고, 세 서비스에서 각각 `createCluster(정의)` 합니다.
- 노드(`ILClusterNode`)에 그 호스트 조정자의 `ilcc_port` 를 싣습니다. 판사 정의와 그 조정자 정의 **두 곳에 같은 값**을 적습니다.
- `setClusterProperty` 로 바꿀 수 있는 것은 자동 기동(어디서나)과 하트비트(판사에서만)뿐입니다. 타입 · ilcc 포트 · 노드를 바꾸려면 세 곳에서 지우고 다시 만듭니다.
- `removeCluster` / `startCluster` / `stopCluster` 는 **접속한 서비스만** 다룹니다. 떠 있어도 지워집니다. `stopCluster` 는 정지를 기다리지 않으므로, 곧바로 다시 띄우려면 상태가 `STOPPED` 인지 먼저 확인하십시오.
- `getClusterDefDiag` 는 새 본문(FORMAT 2)을 읽습니다. 판사에 물으면 두 조정자의 상태가, 조정자에 물으면 판사와의 관계가 옵니다.
- 기동/정지 실패 사유가 "-1" 대신 엔진이 준 사유로 올라옵니다.
- **없어진 API**: `addClusterNode` / `removeClusterNode`, `getLastClusterWarning`, 이름 · 포트로 만드는 `createCluster` 형태, `ILClusterProperty` 의 `priSvc` · `secSvc` · `witSvc` · 세 포트 · `role` · `add_node` · `remove_node`, 옛 진단 접근자(`getLocal`, `getLocalAddress`, `isMissing`, `hasAgreement` 등), 상수 `CLUSTER_NO_NODES_MIN_REVISION` · `CLUSTER_DEFDIAG_MAX_FORMAT` · `CLUSTER_DEFDIAG_MAX_ELAPSED_MS`.

그 밖:

- 프레임 길이가 999,999,999 바이트를 넘으면 **보내기 전에** `MIMQE_MSG_SIZE_OVER` 로 막습니다. 엔진은 그런 프레임을 받으면 사유 없이 연결을 끊습니다. `maxRequestSize` 는 999,999,974 를 넘지 않게 보정됩니다.

## 0.6.9 변경 - 파티션을 클라이언트가 정한다 (엔진 7.0.1.3401 이상 필요)

엔진이 더는 파티션을 고르지 않습니다. 이 판부터 producer 와 관리 API 단건 발행이 파티션을 정해 보냅니다(Kafka 와 같은 방식).

- **키가 있으면** Kafka 와 같은 murmur2 로 **UTF-8 키 바이트**를 해시해 정합니다. 같은 키는 파이썬·자바 어느 클라이언트든 같은 파티션으로 갑니다.
- **키가 없으면** producer 는 한 파티션에 `batchSize` 바이트씩 붙였다가 다음 파티션으로 넘깁니다(Kafka 3.3+ 균일 스티키). 관리 API 단건 발행은 토픽별 라운드로빈입니다.
- 토픽의 파티션 수는 처음 보낼 때 한 번 묻습니다. **알 수 없으면(없는 토픽 등) `send()` 가 예외를 던집니다.** `acks=0` 도 같습니다.
- 멱등 발행(`enableIdempotence(True)`)은 (토픽, 파티션)마다 번호를 매깁니다. 한 레코드가 거절돼도(너무 큼 등) 뒤 레코드는 계속 적재되고, 여러 건을 한 봉투에 묶어 보냅니다.
- `maxRequestSize`(기본 1MB)는 레코드 한 건과 **봉투 하나의 크기**를 함께 묶습니다.

★ 그 이전 엔진에서는 발행이 모두 실패합니다(파티션 수를 물을 수 없다). 그런 엔진에는 0.6.8 을 쓰십시오.

## 0.2.0 신규 - pub/sub 토픽

큐에 더해 **pub/sub 토픽**을 지원합니다. 발행된 메시지는 retention 정책이 지울 때까지 보존되어
모든 구독자에게 각자의 커서로 전달됩니다(팬아웃). 큐와 달리 소비해도 사라지지 않습니다.

> 엔진 v7.1.1 rev 3265 이상이 필요합니다.

### 구독

```python
from ilink.qmgr import ILQmgr
from ilink.topic import ILTopic, ILSubscribeOptions

qmgr = ILQmgr()
qmgr.connect("127.0.0.1", 19999, "order-svc", True)

topic = qmgr.access_topic("ORDER.EVENT")
sub = topic.subscribe("settlement", ILSubscribeOptions()
                      .start_mode(ILTopic.START_EARLIEST)
                      .commit_mode(ILTopic.COMMIT_MANUAL))

for msg in sub.read_batch(100, 3000):
    print(msg.get_offset(), msg.get_key(), msg.get_data_string())
    sub.commit(msg)
```

콜백(push)으로 받을 수도 있습니다.

```python
sub.listen(lambda m: print(m.get_data_string()),
           lambda e: print("error:", e))
...
sub.stop_listening()
```

### 발행

발행은 producer 단일 경로입니다. producer 하나는 토픽 하나에 묶입니다(0.7.0). 동기 발행은 `send().get()`을 씁니다.

```python
from ilink.producer import ILProducerConfig, ILProducerRecord

prod = qmgr.create_producer("ORDER.EVENT", ILProducerConfig())
meta = prod.send(ILProducerRecord("ORDER.EVENT", "k1", b"payload",
                                  properties={"trace-id": "abc"})).get()
print(meta.get_partition(), meta.get_offset())
prod.close()
```

### 와일드카드(패턴) 구독

패턴에 맞는 여러 토픽을 한꺼번에 구독합니다.

```python
pat = qmgr.access_pattern("ORDER.*")
print(pat.resolve())                    # 지금 매칭되는 토픽 (구독 안 함)

ps = pat.subscribe("audit")
m = ps.read(3000)
print(m.get_topic_name(), m.get_data_string())
```

**`*`는 구분자 `.`를 포함해 매칭합니다.** `ORDER.*`는 `ORDER.KR`뿐 아니라 `ORDER.KR.SUB`도 잡습니다.
**정규식이 아니라 글로브입니다.** `.`은 리터럴이라 `ORDER.*`는 `ORDERING`을 잡지 않습니다.

### 토픽 관리

토픽 생성/삭제/속성변경은 관리 표면 전용입니다.

```python
from ilink.admin import ILAdminService

svc = ILAdminService(); svc.connect("127.0.0.1", 9998)
adm = svc.accessAdminQmgr("QMGR1")
adm.createTopic("ORDER.EVENT", "partitions=3")
print(adm.getTopicList())
```

## 0.2.0 신규 - 클러스터(HA) 페일오버

클러스터 큐 관리자에 접속하면 후보 주소를 캐싱해 두었다가, 리더가 바뀌어도 따라갑니다.

```python
qmgr.connect("10.0.0.1", 5000, "APP", True)   # 주소 하나면 됩니다
print(qmgr.get_cluster_addresses())           # ['10.0.0.1:5000', '10.0.0.2:5000']
qmgr.reconnect()                              # 새 리더를 찾아 재접속

# 첫 접속 시점의 장애까지 대비하려면 목록으로 (포트 인자 없음)
qmgr.connect("10.0.0.1:5000,10.0.0.2:5000", "APP", True)
```

접속에 성공하면 핸드셰이크 직후 나머지 노드 주소를 서버에서 받아 캐싱하므로
**주소는 하나만 주면 됩니다.** 팔로워를 지목해도 거부에 실린 리더 힌트를 따라 자동으로
리더에 붙으며, 이는 **단일 주소든 목록이든 마찬가지**입니다. 그때 알게 된 리더 주소는
**후보 캐시에 들어가** 목록으로 받은 주소와 똑같이 쓰이고, `set_endpoint_cache_file()` 로
경로를 지정해 뒀다면 파일에도 반영됩니다(이미 있으면 그대로 둡니다).

목록은 **첫 접속 시점**의 장애까지 대비할 때 씁니다 — 하나만 준 그 주소가 죽어 있으면
힌트를 줄 상대조차 없기 때문입니다.

### 0.4.4 변경 - 자동 재접속은 옵션이 아니라 기본 동작

캐싱된 endpoint 목록이 있으면(= 클러스터) 통신 장애로 실패한 put/get 을 페일오버
재접속 후 **한 번 자동 재시도합니다.** 켜고 끄는 설정은 없습니다
(`set_auto_reconnect()` 는 제거했습니다 — 아무 일도 하지 않는 세터를 남겨 두면
"껐는데 왜 재접속하냐"는 혼란만 생깁니다).

| 상황 | 동작 |
|---|---|
| 미커밋 트랜잭션 **있음** | 예외를 올립니다. 앱이 `reconnect()` 후 트랜잭션을 처음부터 다시 수행 |
| 미커밋 트랜잭션 **없음** (auto-commit 포함) | 내부에서 재접속 후 1회 재시도 |
| endpoint 목록 없음 (비클러스터/구엔진) | 예외를 올립니다 - 갈 곳이 없습니다 |

> 재시도는 **at-least-once** 입니다. 서버가 처리한 뒤 응답이 유실된 시점에 재시도하면
> **중복 put 이 생길 수 있습니다.** 재시도는 같은 msgId 로 나가므로 수신측에서 메시지 ID
> 로 걸러낼 수 있습니다. 미커밋 트랜잭션이 없을 때만 재시도하므로 트랜잭션 유실은 없습니다.

### 장애 전환(failover) 시 앱이 해야 할 일

리더가 죽으면 새 리더가 뽑힐 때까지 **아무도 접속을 받지 않습니다.** 실측(2노드 클러스터,
엔진 7.0.1.3341) **약 15~20초**가 걸립니다. 그 사이의 재접속 시도는 정상적으로 실패하므로,
**한 번 실패했다고 끝내지 말고 재시도**해야 합니다.

세션 종류에 따라 앱이 할 일이 갈립니다.

| 세션 | 실패 시점에 미커밋 | 앱이 할 일 |
|---|---|---|
| auto-commit | 없음 | **아무것도 안 해도 됩니다.** 그 연산을 다시 부르기만 하면 라이브러리가 재접속·재시도합니다 |
| transacted | 없음 | 위와 같습니다 |
| transacted | 있음 | `reconnect()` 로 자리를 옮기고 **트랜잭션을 처음부터 다시** 수행해야 합니다 |

`access_queue()` 는 **자동 재접속 대상이 아닙니다.** 끊긴 세션에서 부르면 그대로 실패하니,
재접속에 성공한 **뒤에** 핸들을 다시 얻으십시오. 기존 핸들을 계속 쓰는 쪽은 스스로 복구됩니다.

#### (1) 트랜잭션 재수행이 필요 없는 경우 — auto-commit

```python
import time
from ilink.qmgr import ILQmgr
from ilink.exception import ILException, ILSessionException, ILOperationException

q = ILQmgr()
q.set_endpoint_cache_file("/var/run/myapp/ilink-endpoints.txt")   # 재기동 대비(선택)
q.connect("10.0.0.1", 5000, "collector", True)                    # auto-commit
queue = q.access_queue("APP.EVENTS")                              # 핸들은 한 번만 얻는다

def publish(payload):
    """페일오버가 나도 이 함수는 그대로 둔다 - 라이브러리가 복구한다."""
    for attempt in range(1, 11):
        try:
            return queue.put(payload)          # 실패하면 내부에서 재접속 + 1회 재시도
        except ILOperationException:
            raise                              # 서버가 거절 - 재시도해도 같다
        except (ILException, ILSessionException):
            if attempt == 10:
                raise                          # 승격이 20초 넘게 안 끝났다
            time.sleep(3)                      # 리더 승격을 기다렸다가 다시
```

앱 코드에 `reconnect()` 가 없다는 점이 요지입니다. 같은 큐 핸들로 `put` 을 다시 부르기만
하면, 승격이 끝난 시점의 호출이 새 리더에 붙어 성공합니다(실측 20.6초에 복구).

#### (2) 트랜잭션 재수행이 필요한 경우 — transacted

```python
import time
from ilink.qmgr import ILQmgr
from ilink.exception import ILException, ILSessionException, ILOperationException

MAX_RETRY = 10

def process_batch(records):
    """한 번의 트랜잭션 = 전부 커밋되거나 전부 없던 일이 된다."""
    q = ILQmgr()
    q.set_endpoint_cache_file("/var/run/myapp/ilink-endpoints.txt")
    q.connect("10.0.0.1", 5000, "billing-svc", False)      # transacted
    try:
        for attempt in range(1, MAX_RETRY + 1):
            try:
                queue = q.access_queue("APP.ORDERS")       # 재접속 뒤 핸들을 다시 얻는다
                for rec in records:
                    queue.put(rec)
                q.commit()                                 # 여기까지 와야 확정된다
                return
            except ILOperationException:
                q.rollback()                               # 서버 거절 - 재시도 무의미
                raise
            except (ILException, ILSessionException):
                if attempt == MAX_RETRY:
                    raise
                try:
                    q.reconnect()                          # 승격 전이면 여기서 또 실패한다
                except Exception:
                    time.sleep(3)                          # 기다렸다가 다음 회차에 다시
                # 루프 처음으로 -> 배치 전체를 다시 넣는다
    finally:
        q.disconnect()
```

읽어야 할 포인트 셋입니다.

1. **재시도 단위는 트랜잭션 전체**입니다. 실패 지점부터 이어붙이면 안 됩니다 — 앞서 넣은
   것들도 커밋되지 않았으므로 함께 사라집니다(실측: 미커밋 1건 유실, 재수행 후 depth 2 정상).
2. **`reconnect()` 자체가 실패할 수 있습니다.** 승격 전에 부르면 실패하는 게 정상이라,
   루프 안에서 간격을 두고 다시 불러야 합니다(실측 2번째 시도, 16.5초에 성공).
3. **`ILOperationException` 과 통신 예외를 갈라 잡습니다.** 전자는 서버가 판단해 거절한
   것이라 재시도가 무의미하고, 후자만 재접속 대상입니다. 파이썬의 예외 클래스는 평면
   구조라 `ILSessionException` 은 `ILException` 의 하위 타입이 **아닙니다** — 통신 장애를
   잡으려면 **둘 다** 적어야 합니다(자바는 모두 `ILException` 으로 감싸므로 하나면 됩니다).

#### 앱이 재기동되는 경우

캐싱은 메모리에만 있으므로 프로세스가 죽으면 사라집니다. 설정에 남은 옛 리더 주소로 다시
붙을 때 이렇게 갈립니다.

| 옛 리더의 상태 | 결과 |
|---|---|
| 살아 있고 **팔로워로 강등** | 그 노드가 리더 힌트를 주므로 **자동으로 새 리더에 접속** |
| **죽어 있음** | 갈 곳이 없어 실패 — `set_endpoint_cache_file()` 을 쓰거나 주소를 목록으로 주십시오 |

```python
q = ILQmgr()
q.set_endpoint_cache_file("/var/run/myapp/ilink-endpoints.txt")
q.connect("10.0.0.1", 5000, "APP", True)   # 이 주소가 죽어 있어도 파일 후보로 붙는다
```

## 0.4.0 신규 - 관리 연결 하나로 pub/sub

`ILAdminService`(관리 포트, 보통 9998) 연결만으로 토픽 발행·구독이 가능합니다.
큐 관리자 리스너 포트에 따로 붙지 않아도 되므로, **방화벽이 관리 포트만 열린 환경**에서
쓸 수 있습니다.

> 엔진 v7.0.1 rev 3295 이상이 필요합니다.

```python
from ilink.admin import ILAdminService
from ilink.exception import ILNoMsgException
from ilink.topic import ILSubscribeOptions, ILTopic

svc = ILAdminService()
svc.connect("127.0.0.1", 9998, "admin-app")
aq = svc.accessAdminQmgr("QM1")

# 발행 - (offset, timestamp, partition)
off, ts, part = aq.publish("ORDER.EVENT", b"payload", key="order-1")

# 구독
topic = aq.accessTopic("ORDER.EVENT")
sub = topic.subscribe("audit", ILSubscribeOptions()
                      .startMode(ILTopic.START_EARLIEST)
                      .commitMode(ILTopic.COMMIT_MANUAL))
try:
    while True:
        try:
            msg = sub.read(3000)
        except ILNoMsgException:
            break
        print(msg.get_offset(), msg.get_key(), msg.get_data_string())
        sub.commit(msg)
finally:
    sub.close()
```

패턴(와일드카드) 구독도 같은 연결로 됩니다.

```python
ps = aq.accessPattern("ORDER.*").subscribe("audit")
```

> **제약**: 관리 연결에는 배치가 없어 **레코드 한 건에 한 번 왕복**합니다. 멱등 발행도
> 지원되지 않습니다(acks=1 고정). 대량 처리나 중복 제거가 필요하면 리스너 포트에
> `ILQmgr` 로 붙어 `ILTopicProducer` 를 쓰세요.

## 0.3.0 신규 - 허브-스포크 연결 전환 헬퍼

허브에 접속해 스포크를 찾고 연결을 전환하는 세 단계를 한 번에 처리하는
`connectToSpoke()`가 추가되었습니다. Java API에도 같은 이름으로 있습니다.

```python
from ilink.admin import ILAdminService

for name in ("S48", "S85"):
    svc = ILAdminService.connectToSpoke("10.10.1.95", 9998, "ADMIN", name)
    try:
        print(name, [q.getName() for q in svc.getQmgrList()])
    finally:
        svc.disconnect()

# 키를 이미 알고 있으면 목록 조회를 건너뛴다 (키는 설정 파일에 저장되어 재기동해도 유지)
svc = ILAdminService.connectToSpokeByKey("10.10.1.95", 9998, "ADMIN", spoke_key)
```

> 연결 전환 후 그 연결은 **해당 스포크에 직접 접속한 것으로 에뮬레이션**됩니다.
> 따라서 전환은 연결당 한 번뿐이고, 다른 스포크로 가려면 새 연결이 필요합니다.
> `connectToSpoke()`가 그 반복을 담당합니다. 실패하면 스스로 연결을 닫으므로
> 소켓이 새지 않습니다.

TCP 연결 방향이 **스포크 → 허브** 한 방향뿐이라 스포크 쪽에 인바운드 포트를 열지 않고도
관리할 수 있습니다. 실측상 릴레이 오버헤드는 없었습니다(`getQmgrList()` 중앙값
릴레이 18.3ms vs 허브 직결 18.4ms).

## 0.2.1 신규 - Java API 옵션 표면 일치

발행/구독 옵션이 Java API와 **같은 이름의 빌더**로 정리되었습니다. 기존 snake 표기
(`start_mode` / `commit_mode` / `linger_ms` ...)도 그대로 쓸 수 있습니다.

```python
from ilink.producer import ILProducerConfig

cfg = (ILProducerConfig()
       .acks(1).lingerMs(5).batchSize(32768)
       .maxRequestSize(1048576)      # 봉투 하나의 상한(레코드 1건도) - 넘는 레코드는 send()가 거부
       .bufferMemory(33554432)       # 미전송 누적 상한 - 차면 maxBlockMs 까지 기다린다
       .retries(3).retryBackoffMs(100)
       .deliveryTimeoutMs(120000)    # 재시도를 포함한 완결 시한
       .enableIdempotence(True))     # PID/시퀀스 중복 제거 (acks=1, maxInFlight 5 이하 필요)

prod = qmgr.create_producer("ORDER.EVENT", cfg)   # 0.7.0: producer 하나 = 토픽 하나
print(prod.get_producer_id(), prod.is_connected())
```

구독 옵션에 리밸런스 리스너/prefetch/수동 파티션이 추가되었고, 파티션을 직접 고르는
`assign()`이 생겼습니다.

```python
sub = topic.subscribe("app1", ILSubscribeOptions()
                      .startMode(ILTopic.START_EARLIEST)
                      .commitMode(ILTopic.COMMIT_MANUAL)
                      .expiryMs(600000)          # 멤버 유휴 만료 10분
                      .prefetch(100)
                      .listener(on_rebalance))

sub = topic.assign("app1", [0, 2], ILTopic.START_EARLIEST)   # 수동 파티션 배정
```

> **주의 (0.2.0에서 올라올 때)**: 옵션 값은 이제 **게터로 읽습니다.**
> `cfg.acks` / `opt.durable` 은 빌더 **메서드**이므로 값이 필요하면
> `cfg.get_acks()` / `opt.is_durable()` 을 쓰세요. `cfg.acks = 0` 같은 **직접 대입은
> 그대로 동작합니다.** 같은 이유로 `ILClusterProperty.isAutoStart` 와
> `ILSpokeProperty.isRunning` 도 Java처럼 **메서드**가 되었습니다
> (`prop.isAutoStart()`).

## 개발자 가이드 문서

**공개 API 779개 전부에 한국어 docstring이 붙어 있습니다.** 편집기에서 함수 위에
마우스를 올리거나 `help()`로 파라미터 타입·기본값·허용값·예외를 바로 확인할 수 있습니다.

```python
help(qmgr.access_queue)
help(ILAdminQmgr.getStatSeries)
```

값 객체(`ILQueueProperty` 등)는 필드 목록이 클래스 docstring에 정리되어 있습니다.
`getX()` / `setX()` 접근자는 필드 이름에서 자동으로 만들어지므로, 필드 목록이 곧
접근자 목록입니다.

```python
help(ILQueueProperty)      # 필드 이름 / 타입 / 기본값 / 의미
```
