channels-nats 0.2.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.
- channels_nats-0.2.0/.gitattributes +1 -0
- channels_nats-0.2.0/.github/workflows/ci.yml +41 -0
- channels_nats-0.2.0/.github/workflows/release.yml +56 -0
- channels_nats-0.2.0/.gitignore +9 -0
- channels_nats-0.2.0/CHANGELOG.md +14 -0
- channels_nats-0.2.0/CLAUDE.md +36 -0
- channels_nats-0.2.0/Makefile +26 -0
- channels_nats-0.2.0/PKG-INFO +188 -0
- channels_nats-0.2.0/README.md +156 -0
- channels_nats-0.2.0/bench/__init__.py +0 -0
- channels_nats-0.2.0/bench/fanout.py +221 -0
- channels_nats-0.2.0/bench/results/fanout-20260908-161957.json +20 -0
- channels_nats-0.2.0/bench/results/fanout-20260908-162356.json +20 -0
- channels_nats-0.2.0/bench/results/wireview-nats-4proc-0.2.0.json +72 -0
- channels_nats-0.2.0/channels_nats/__init__.py +6 -0
- channels_nats-0.2.0/channels_nats/layer.py +270 -0
- channels_nats-0.2.0/channels_nats/serializers.py +48 -0
- channels_nats-0.2.0/pyproject.toml +70 -0
- channels_nats-0.2.0/tests/conftest.py +89 -0
- channels_nats-0.2.0/tests/test_layer.py +146 -0
- channels_nats-0.2.0/uv.lock +434 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* text=auto eol=lf
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
env:
|
|
9
|
+
NATS_VERSION: v2.14.6
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
os: [ubuntu-latest, windows-latest]
|
|
17
|
+
python-version: ["3.10", "3.13"]
|
|
18
|
+
runs-on: ${{ matrix.os }}
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
- uses: astral-sh/setup-uv@v4
|
|
22
|
+
- run: uv python install ${{ matrix.python-version }}
|
|
23
|
+
|
|
24
|
+
- name: Install nats-server (Linux)
|
|
25
|
+
if: runner.os == 'Linux'
|
|
26
|
+
run: |
|
|
27
|
+
curl -sL "https://github.com/nats-io/nats-server/releases/download/${NATS_VERSION}/nats-server-${NATS_VERSION}-linux-amd64.tar.gz" | tar xz -C "$RUNNER_TEMP"
|
|
28
|
+
echo "$RUNNER_TEMP/nats-server-${NATS_VERSION}-linux-amd64" >> "$GITHUB_PATH"
|
|
29
|
+
|
|
30
|
+
- name: Install nats-server (Windows)
|
|
31
|
+
if: runner.os == 'Windows'
|
|
32
|
+
shell: pwsh
|
|
33
|
+
run: |
|
|
34
|
+
# Extract outside the checkout: a ./nats directory would make ruff treat `nats` as first-party.
|
|
35
|
+
Invoke-WebRequest "https://github.com/nats-io/nats-server/releases/download/$env:NATS_VERSION/nats-server-$env:NATS_VERSION-windows-amd64.zip" -OutFile "$env:RUNNER_TEMP\nats.zip"
|
|
36
|
+
Expand-Archive "$env:RUNNER_TEMP\nats.zip" -DestinationPath "$env:RUNNER_TEMP\nats"
|
|
37
|
+
"$env:RUNNER_TEMP\nats\nats-server-$env:NATS_VERSION-windows-amd64" | Out-File -FilePath $env:GITHUB_PATH -Append
|
|
38
|
+
|
|
39
|
+
- run: uv sync --all-extras
|
|
40
|
+
- run: uv run ruff check .
|
|
41
|
+
- run: uv run pytest -v
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Tag push (v*) -> build -> publish to PyPI via trusted publishing -> GitHub release.
|
|
4
|
+
# PyPI side: pypi.org > Account settings > Publishing > add a pending publisher with
|
|
5
|
+
# owner itda-work, repository channels-nats, workflow release.yml, environment pypi.
|
|
6
|
+
# No API token is stored anywhere.
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags: ["v*"]
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v4
|
|
18
|
+
|
|
19
|
+
- name: Check that the tag matches pyproject.toml
|
|
20
|
+
run: |
|
|
21
|
+
version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
22
|
+
if [ "v$version" != "$GITHUB_REF_NAME" ]; then
|
|
23
|
+
echo "tag $GITHUB_REF_NAME does not match pyproject version $version"
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
- run: uv build
|
|
28
|
+
- run: uvx twine check dist/*
|
|
29
|
+
|
|
30
|
+
- uses: actions/upload-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
|
|
35
|
+
publish:
|
|
36
|
+
needs: build
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
environment: pypi
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write # PyPI trusted publishing (OIDC)
|
|
41
|
+
contents: write # GitHub release
|
|
42
|
+
steps:
|
|
43
|
+
- uses: actions/download-artifact@v4
|
|
44
|
+
with:
|
|
45
|
+
name: dist
|
|
46
|
+
path: dist/
|
|
47
|
+
|
|
48
|
+
- name: Publish to PyPI
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
50
|
+
|
|
51
|
+
- name: GitHub release
|
|
52
|
+
uses: softprops/action-gh-release@v2
|
|
53
|
+
with:
|
|
54
|
+
files: dist/*
|
|
55
|
+
generate_release_notes: true
|
|
56
|
+
prerelease: ${{ contains(github.ref_name, 'a') || contains(github.ref_name, 'b') || contains(github.ref_name, 'rc') }}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Keep a Changelog 형식. subject 규약이 바뀌면 여기와 README에 남기고 메이저(1.0 전에는 마이너)를 올린다.
|
|
4
|
+
|
|
5
|
+
## [0.2.0] - 2026-09-08
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- 프로세스 전용 채널(`specific.<process>!<id>`)은 채널마다 구독하지 않고 프로세스당 구독 하나(`<prefix>.pc.<process>`)로 받는다. 전체 채널 이름은 NATS 헤더 `Channel`로 전달된다. 컨슈머 연결 하나의 비용이 NATS 구독과 콜백 태스크에서 로컬 대기열 하나로 준다 (#1)
|
|
10
|
+
- `channel_subject()`가 `!`가 있는 이름에 대해 `pc` subject를 돌려준다. `ch` subject는 일반 채널 전용
|
|
11
|
+
|
|
12
|
+
## [0.1.0] - 2026-09-08
|
|
13
|
+
|
|
14
|
+
- 첫 버전. `send`/`receive`/`new_channel`/`group_*`/`flush`, json과 msgpack, 루프별 연결, Ubuntu·Windows CI, fan-out 벤치
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# channels-nats AI Guide
|
|
2
|
+
|
|
3
|
+
> Django Channels 채널 레이어의 NATS 구현. 사용자 코드는 Channels 표준 그대로이고, 바뀌는 것은 settings의 BACKEND뿐이어야 한다.
|
|
4
|
+
|
|
5
|
+
## 정본
|
|
6
|
+
|
|
7
|
+
| 무엇 | 정본 |
|
|
8
|
+
|------|------|
|
|
9
|
+
| 의존성, 지원 Python | `pyproject.toml` |
|
|
10
|
+
| 레이어 의미론과 subject 규약 | `README.md`, `channels_nats/layer.py` 모듈 docstring |
|
|
11
|
+
| Channels 레이어 스펙 | `channels.layers.BaseChannelLayer` (send/receive/new_channel/group_*/flush) |
|
|
12
|
+
|
|
13
|
+
## 지도
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
channels_nats/
|
|
17
|
+
├── __init__.py NatsChannelLayer 재export
|
|
18
|
+
├── layer.py 레이어 본체. 이벤트 루프별 연결, 채널별 로컬 mailbox, 그룹당 구독 하나
|
|
19
|
+
└── serializers.py json 기본, msgpack 선택
|
|
20
|
+
tests/ nats-server 바이너리를 띄우는 통합 테스트 (NATS_SERVER, PATH, ~/go/bin 순서로 탐색)
|
|
21
|
+
bench/fanout.py group_send fan-out 지연·처리량. InMemory 레이어와 비교
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 규약
|
|
25
|
+
|
|
26
|
+
- 사용자 쪽 API를 늘리지 않는다. 옵션은 `CONFIG`로만.
|
|
27
|
+
- subject 형식(`<prefix>.ch.<channel>`, `<prefix>.grp.<group>`)은 외부 계약이다. 바꾸면 README와 CHANGELOG에 남기고 메이저를 올린다.
|
|
28
|
+
- Windows를 1급으로 지원한다. Unix 소켓, fork, 시그널에 의존하지 않는다. CI는 ubuntu와 windows 둘 다.
|
|
29
|
+
- 커밋 메시지는 영어 Conventional Commits. 문서는 한국어.
|
|
30
|
+
- 릴리스는 `pyproject.toml`의 version을 올리고 CHANGELOG에 절을 추가한 뒤 `v<version>` 태그를 푸시한다. `release.yml`이 빌드해 PyPI(trusted publishing, environment `pypi`)와 GitHub Release에 올린다. 토큰은 저장하지 않는다.
|
|
31
|
+
|
|
32
|
+
## 함정
|
|
33
|
+
|
|
34
|
+
- NATS는 저장이 없다. 구독 전 발행은 사라진다. 테스트는 항상 수신자를 먼저 만든다.
|
|
35
|
+
- `subscribe` 뒤 `flush()`를 기다려야 서버가 구독을 알고 있다. 이를 빼면 다른 프로세스의 직후 발행을 놓친다.
|
|
36
|
+
- 이벤트 루프가 다르면 연결도 다르다. `async_to_sync`가 만든 루프는 별도 연결을 갖는다.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
.PHONY: install test lint format check bench nats
|
|
2
|
+
|
|
3
|
+
install:
|
|
4
|
+
uv sync --all-extras
|
|
5
|
+
|
|
6
|
+
test:
|
|
7
|
+
uv run pytest
|
|
8
|
+
|
|
9
|
+
lint:
|
|
10
|
+
uv run ruff check .
|
|
11
|
+
uv run ruff format --check .
|
|
12
|
+
|
|
13
|
+
format:
|
|
14
|
+
uv run ruff check --fix .
|
|
15
|
+
uv run ruff format .
|
|
16
|
+
|
|
17
|
+
check:
|
|
18
|
+
uv run pyright
|
|
19
|
+
|
|
20
|
+
# Fan-out benchmark (see bench/fanout.py). Override: make bench ARGS="--members 5000 --processes 8"
|
|
21
|
+
bench:
|
|
22
|
+
uv run python -m bench.fanout $(ARGS)
|
|
23
|
+
|
|
24
|
+
# Run a local nats-server (PATH, or ~/go/bin from `go install`)
|
|
25
|
+
nats:
|
|
26
|
+
$${NATS_SERVER:-nats-server} -p 4222
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: channels-nats
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: NATS-backed channel layer for Django Channels: one Go binary instead of Redis, Windows-friendly
|
|
5
|
+
Project-URL: Repository, https://github.com/itda-work/channels-nats
|
|
6
|
+
Author: itda-work
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Framework :: Django
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: channels<5,>=4
|
|
21
|
+
Requires-Dist: nats-py<3,>=2.6
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: django>=4.2; extra == 'dev'
|
|
24
|
+
Requires-Dist: msgpack>=1.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pyright; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
29
|
+
Provides-Extra: msgpack
|
|
30
|
+
Requires-Dist: msgpack>=1.0; extra == 'msgpack'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# channels-nats
|
|
34
|
+
|
|
35
|
+
> Django Channels 채널 레이어를 NATS 위에 올린다. Redis 대신 Go 단일 바이너리 하나. Windows, macOS, Linux 모두 네이티브.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# settings.py — 바뀌는 것은 이 블록뿐이다
|
|
39
|
+
CHANNEL_LAYERS = {
|
|
40
|
+
"default": {
|
|
41
|
+
"BACKEND": "channels_nats.NatsChannelLayer",
|
|
42
|
+
"CONFIG": {"servers": ["nats://127.0.0.1:4222"]},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
컨슈머, `group_send`, `get_channel_layer()` 등 Channels 코드는 그대로다. wireview처럼 채널 레이어 위에 올라간 라이브러리도 그대로다.
|
|
48
|
+
|
|
49
|
+
## 왜
|
|
50
|
+
|
|
51
|
+
- **Windows에서 WSL2·Docker 없이** 여러 Python 프로세스가 한 레이어를 공유한다. `nats-server.exe`를 PATH에 두면 끝.
|
|
52
|
+
- **`group_send`가 발행 한 번**이다. channels_redis는 그룹 멤버 수만큼 명령을 보내지만, NATS는 서버(Go)가 뿌린다.
|
|
53
|
+
- **subject가 계약**이라 Python 워커든 Go 프런트든 같은 레이어에 합류할 수 있다 (아래 규약).
|
|
54
|
+
|
|
55
|
+
## 플랫폼
|
|
56
|
+
|
|
57
|
+
| | NATS (`nats-server`) | Valkey / Redis |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| Linux | 네이티브 바이너리 | 네이티브 |
|
|
60
|
+
| macOS | 네이티브, `brew install nats-server` | 네이티브, `brew install valkey` |
|
|
61
|
+
| Windows | 네이티브 `.exe` ([릴리스 zip](https://github.com/nats-io/nats-server/releases)) | 공식 빌드 없음. WSL2·Docker, 또는 Memurai·Garnet 같은 호환 서버 |
|
|
62
|
+
|
|
63
|
+
Go 툴체인이 있으면 어느 OS에서든 `go install github.com/nats-io/nats-server/v2@latest`로 빌드된다.
|
|
64
|
+
|
|
65
|
+
## 설치
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install channels-nats # 또는 uv add channels-nats
|
|
69
|
+
pip install "channels-nats[msgpack]" # bytes를 실어 보내야 하면
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
서버는 `nats-server -p 4222`로 띄운다. 인증이 필요하면 `nats-server --auth <token>`과 `CONFIG: {"servers": ["nats://<token>@host:4222"]}`.
|
|
73
|
+
|
|
74
|
+
## 설정
|
|
75
|
+
|
|
76
|
+
| 키 | 기본값 | 의미 |
|
|
77
|
+
|----|--------|------|
|
|
78
|
+
| `servers` | `"nats://127.0.0.1:4222"` | 문자열 또는 목록 |
|
|
79
|
+
| `prefix` | `"channels"` | subject 접두어. 한 NATS를 여러 앱이 나눠 쓸 때 구분 |
|
|
80
|
+
| `expiry` | `60` | 초. 이보다 오래 대기한 메시지는 `receive`가 버린다 |
|
|
81
|
+
| `capacity` | `100` | 채널당 로컬 대기열 크기. 넘치면 새 메시지를 버리고 경고 로그 |
|
|
82
|
+
| `channel_capacity` | `None` | 채널 이름 패턴별 용량 (Channels 규약과 같음) |
|
|
83
|
+
| `serializer` | `"json"` | `"json"` 또는 `"msgpack"` |
|
|
84
|
+
| `connect_options` | `{}` | `nats.connect()`에 그대로 전달 (재접속, TLS 등) |
|
|
85
|
+
|
|
86
|
+
## Channels 규약과 다른 점
|
|
87
|
+
|
|
88
|
+
NATS는 저장 없는 at-most-once pub/sub이다. 이 레이어가 그 위에서 지키는 것과 못 지키는 것.
|
|
89
|
+
|
|
90
|
+
- **수신자가 먼저 있어야 한다.** 채널의 첫 `receive()`(또는 `new_channel()`) 전에 발행된 메시지는 사라진다. 컨슈머는 연결 시 구독하므로 일반 코드에는 영향이 없고, 임의 이름의 채널에 먼저 `send`하고 나중에 `receive`하는 패턴만 다르다.
|
|
91
|
+
- **`ChannelFull`은 발생하지 않는다.** 보내는 쪽은 상대 대기열을 모른다. 대신 받는 쪽이 넘치는 메시지를 버린다. `channels_redis`는 큐가 `capacity`를 넘으면 보내는 쪽에 이 예외를 던지므로, 그것을 잡던 코드는 여기서 아무 신호도 받지 못한다.
|
|
92
|
+
- **버퍼가 있는 곳이 다르다.** `channels_redis`는 메시지를 Redis 안에 `expiry`(기본 60초)까지 보관하므로 받는 쪽이 아직 없어도 나중에 받는다. 이 레이어의 버퍼는 **받는 프로세스의 로컬 mailbox**이고 구독이 생긴 뒤에만 존재한다. 즉 "버퍼가 없다"가 아니라 "버퍼가 브로커가 아니라 구독자 안에 있다"가 정확하다.
|
|
93
|
+
- **그룹 멤버십은 프로세스 안에 있다.** 프로세스가 죽으면 그 멤버십도 사라지므로 `group_expiry`는 형식상 유지된다.
|
|
94
|
+
- **연결은 이벤트 루프마다 하나**다. Django 시그널이나 뷰에서 `async_to_sync(layer.group_send)`를 불러도 된다.
|
|
95
|
+
|
|
96
|
+
## Core NATS만 쓴다 — JetStream을 쓰지 않는 이유
|
|
97
|
+
|
|
98
|
+
이 레이어는 Core NATS의 `publish`/`subscribe`만 쓴다. JetStream(스트림, 소비자, `.blk` 파일)을 만들지도 열지도 않고, 의존은 `nats-py` 하나다. 디스크에 아무것도 쓰지 않는다.
|
|
99
|
+
|
|
100
|
+
**Channels 채널 레이어에 JetStream이 필요 없기 때문이다.** Channels 스펙이 요구하는 전달 보장은 at-most-once이고, 그것은 Core NATS가 이미 주는 것이다. 영속 스트림을 얹으면 레이어가 보장하지 않는 것을 보장하는 것처럼 보이게 만들면서 운영 부담(스토리지, 보존 정책, 소비자 상태)만 늘어난다.
|
|
101
|
+
|
|
102
|
+
**Jepsen의 NATS 보고서는 이 레이어에 해당하지 않는다.** 2025-12 [Jepsen: NATS 2.12.1](https://jepsen.io/analyses/nats-2.12.1)이 `.blk` 파일의 단일 비트 오류로 승인된 쓰기 1,367,069건 중 679,153건(49.7%)이 사라지는 것을 보고했다. 이 레이어를 쓸지 판단할 때 자주 인용될 문서라 범위를 적어 둔다.
|
|
103
|
+
|
|
104
|
+
- **검증 대상은 JetStream뿐이다.** 보고서가 "We tested NATS JetStream"이라 밝히고, Core NATS는 "Regular NATS streams are allowed to drop messages"라며 범위에서 제외했다.
|
|
105
|
+
- **결함은 전부 디스크 영속성 계층의 것이다.** `.blk` 파일 손상(#7549), 스냅샷 파일 손상(#7556), 지연된 fsync(#7564), split-brain(#7567). 프로세스 크래시로 스트림이 통째로 사라지던 #6888은 2.10.23에서 고쳐졌다.
|
|
106
|
+
- **이 레이어에는 스트림도 `.blk` 파일도 없다.** 따라서 위 결함이 재현될 표면이 없다.
|
|
107
|
+
- **다만 "in-memory는 장애에 강하다"도 이 보고서의 결론이 아니다.** Jepsen은 memory storage 스트림도 Core NATS도 시험하지 않았다. 시험되지 않은 것은 안전이 입증된 것이 아니다. 이 레이어에 기대야 할 보장은 위 "Channels 규약과 다른 점"에 적힌 것, 그것뿐이다.
|
|
108
|
+
|
|
109
|
+
나중에 영속성이 필요해지면(재연결 중 놓친 메시지 재생 같은) 그때는 이 보고서가 정면으로 해당한다. 그런 기능은 Channels 레이어의 계약 밖이므로 여기가 아니라 애플리케이션에서 다룰 일이다.
|
|
110
|
+
|
|
111
|
+
## subject 규약
|
|
112
|
+
|
|
113
|
+
| subject | 의미 |
|
|
114
|
+
|---------|------|
|
|
115
|
+
| `<prefix>.pc.<process>` | 프로세스 전용 채널 `specific.<process>!<id>`로의 `send`. 전체 채널 이름은 NATS 헤더 `Channel`에 실리고, 받은 프로세스가 로컬에서 라우팅한다. 프로세스당 구독 하나 |
|
|
116
|
+
| `<prefix>.ch.<channel>` | `!`가 없는 일반 채널로의 `send`. 채널당 구독 하나 |
|
|
117
|
+
| `<prefix>.grp.<group>` | `group_send(group, message)`. 그룹에 멤버가 있는 프로세스마다 구독 하나 |
|
|
118
|
+
|
|
119
|
+
본문은 serializer로 직렬화한 Channels 메시지 dict다. 컨슈머 연결 하나의 비용은 로컬 대기열 하나이고 NATS 구독이 아니다. 이 규약만 지키면 Go로 만든 WebSocket 프런트가 Python 없이도 같은 그룹에 뿌릴 수 있다. 그때도 Django 쪽 코드는 바뀌지 않는다.
|
|
120
|
+
|
|
121
|
+
## Windows 운영
|
|
122
|
+
|
|
123
|
+
`nats-server.exe` 하나가 전부다. Windows 서비스로 올리는 방법.
|
|
124
|
+
|
|
125
|
+
1. [릴리스 zip](https://github.com/nats-io/nats-server/releases)을 `C:\nats\`에 푼다.
|
|
126
|
+
2. 설정 파일 `C:\nats\nats.conf`를 만든다. 외부에 열지 않고 토큰을 요구하는 최소 구성이다.
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
listen: 127.0.0.1:4222
|
|
130
|
+
authorization { token: "긴-무작위-문자열" }
|
|
131
|
+
log_file: "C:\nats\nats.log"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
3. 서비스로 등록하고 시작한다 (관리자 PowerShell). nats-server는 Windows 서비스 제어를 직접 지원한다.
|
|
135
|
+
|
|
136
|
+
```powershell
|
|
137
|
+
sc.exe create nats-server binPath= "C:\nats\nats-server.exe -c C:\nats\nats.conf" start= auto
|
|
138
|
+
sc.exe start nats-server
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
4. Django 쪽은 URL에 토큰을 넣는다.
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
CHANNEL_LAYERS = {
|
|
145
|
+
"default": {
|
|
146
|
+
"BACKEND": "channels_nats.NatsChannelLayer",
|
|
147
|
+
"CONFIG": {"servers": [f"nats://{os.environ['NATS_TOKEN']}@127.0.0.1:4222"]},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
여러 Python 프로세스(daphne 등)는 같은 URL로 붙으면 한 레이어를 공유한다. SQLite를 쓰는 단일 서버라면 이것으로 멀티프로세스 구성이 끝난다. 상태 확인은 `sc.exe query nats-server`, 로그는 `nats.log`, 재시작은 `sc.exe stop`과 `start`다.
|
|
153
|
+
|
|
154
|
+
macOS와 Linux에서는 `brew services start nats-server` 또는 systemd 유닛에 같은 설정 파일을 쓴다.
|
|
155
|
+
|
|
156
|
+
## 벤치마크
|
|
157
|
+
|
|
158
|
+
`make bench`가 프로세스 P개에 멤버 채널 N개를 나눠 구독시키고 `group_send`를 M회 발행해, 발행에서 각 멤버의 `receive`까지의 지연과 처리량을 잰다. 결과는 `bench/results/`에 남는다. 아래는 macOS arm64, Python 3.12, nats-server 2.14.6에서 잰 값이다 (2026-09-08).
|
|
159
|
+
|
|
160
|
+
| 구성 | 전달 | p50 | p99 | 처리량 |
|
|
161
|
+
|------|-----:|----:|----:|-------:|
|
|
162
|
+
| 멤버 1,000, 프로세스 4, 발행 50회 | 50,000 / 50,000 | 0.83 ms | 1.46 ms | 92k/s (발행 간격 10 ms에 묶임) |
|
|
163
|
+
| 멤버 10,000, 프로세스 8, 발행 20회 | 200,000 / 200,000 | 4.3 ms | 16.5 ms | 924k/s |
|
|
164
|
+
|
|
165
|
+
참고로 InMemory 레이어(프로세스 하나)는 멤버 1,000의 `group_send`에 62 ms, 10,000에 10.7초가 걸린다. Python이 멤버 수만큼 돌기 때문이고, NATS에서는 그 일이 Go 서버로 넘어간다.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
make bench ARGS="--members 5000 --processes 8 --messages 50"
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## 실전 확인
|
|
172
|
+
|
|
173
|
+
django-wireview의 테스트 프로젝트와 브라우저 E2E가 이 레이어 위에서 통과한다. daphne 4개를 NATS로 묶은 2,000 연결 실측(항목 5개 컴포넌트)은 다음과 같고, InMemory 레이어의 daphne 1개는 브로드캐스트 862 ms, 이벤트 3,013/s, 연결당 46 KB였다.
|
|
174
|
+
|
|
175
|
+
| channels-nats | 연결당 RSS | join/s | 이벤트/s | 브로드캐스트 |
|
|
176
|
+
|---|---:|---:|---:|---:|
|
|
177
|
+
| 0.1.0 채널당 구독 | 61.3 KB | 1,861 | 11,621 | 202 ms |
|
|
178
|
+
| 0.2.0 프로세스당 구독 | 55.3 KB | 2,167 | 11,758 | 143 ms |
|
|
179
|
+
|
|
180
|
+
결과 JSON은 `bench/results/wireview-nats-4proc-0.2.0.json`이다. 수치와 재현 명령은 [django-wireview의 설계 문서](https://github.com/itda-work/django-wireview/blob/main/docs/design/transport-abstraction.md)에 있다.
|
|
181
|
+
|
|
182
|
+
## 개발
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
uv sync --all-extras
|
|
186
|
+
NATS_SERVER=~/go/bin/nats-server uv run pytest # PATH에 있으면 환경변수 불필요
|
|
187
|
+
uv run ruff check . && uv run pyright
|
|
188
|
+
```
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# channels-nats
|
|
2
|
+
|
|
3
|
+
> Django Channels 채널 레이어를 NATS 위에 올린다. Redis 대신 Go 단일 바이너리 하나. Windows, macOS, Linux 모두 네이티브.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
# settings.py — 바뀌는 것은 이 블록뿐이다
|
|
7
|
+
CHANNEL_LAYERS = {
|
|
8
|
+
"default": {
|
|
9
|
+
"BACKEND": "channels_nats.NatsChannelLayer",
|
|
10
|
+
"CONFIG": {"servers": ["nats://127.0.0.1:4222"]},
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
컨슈머, `group_send`, `get_channel_layer()` 등 Channels 코드는 그대로다. wireview처럼 채널 레이어 위에 올라간 라이브러리도 그대로다.
|
|
16
|
+
|
|
17
|
+
## 왜
|
|
18
|
+
|
|
19
|
+
- **Windows에서 WSL2·Docker 없이** 여러 Python 프로세스가 한 레이어를 공유한다. `nats-server.exe`를 PATH에 두면 끝.
|
|
20
|
+
- **`group_send`가 발행 한 번**이다. channels_redis는 그룹 멤버 수만큼 명령을 보내지만, NATS는 서버(Go)가 뿌린다.
|
|
21
|
+
- **subject가 계약**이라 Python 워커든 Go 프런트든 같은 레이어에 합류할 수 있다 (아래 규약).
|
|
22
|
+
|
|
23
|
+
## 플랫폼
|
|
24
|
+
|
|
25
|
+
| | NATS (`nats-server`) | Valkey / Redis |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| Linux | 네이티브 바이너리 | 네이티브 |
|
|
28
|
+
| macOS | 네이티브, `brew install nats-server` | 네이티브, `brew install valkey` |
|
|
29
|
+
| Windows | 네이티브 `.exe` ([릴리스 zip](https://github.com/nats-io/nats-server/releases)) | 공식 빌드 없음. WSL2·Docker, 또는 Memurai·Garnet 같은 호환 서버 |
|
|
30
|
+
|
|
31
|
+
Go 툴체인이 있으면 어느 OS에서든 `go install github.com/nats-io/nats-server/v2@latest`로 빌드된다.
|
|
32
|
+
|
|
33
|
+
## 설치
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install channels-nats # 또는 uv add channels-nats
|
|
37
|
+
pip install "channels-nats[msgpack]" # bytes를 실어 보내야 하면
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
서버는 `nats-server -p 4222`로 띄운다. 인증이 필요하면 `nats-server --auth <token>`과 `CONFIG: {"servers": ["nats://<token>@host:4222"]}`.
|
|
41
|
+
|
|
42
|
+
## 설정
|
|
43
|
+
|
|
44
|
+
| 키 | 기본값 | 의미 |
|
|
45
|
+
|----|--------|------|
|
|
46
|
+
| `servers` | `"nats://127.0.0.1:4222"` | 문자열 또는 목록 |
|
|
47
|
+
| `prefix` | `"channels"` | subject 접두어. 한 NATS를 여러 앱이 나눠 쓸 때 구분 |
|
|
48
|
+
| `expiry` | `60` | 초. 이보다 오래 대기한 메시지는 `receive`가 버린다 |
|
|
49
|
+
| `capacity` | `100` | 채널당 로컬 대기열 크기. 넘치면 새 메시지를 버리고 경고 로그 |
|
|
50
|
+
| `channel_capacity` | `None` | 채널 이름 패턴별 용량 (Channels 규약과 같음) |
|
|
51
|
+
| `serializer` | `"json"` | `"json"` 또는 `"msgpack"` |
|
|
52
|
+
| `connect_options` | `{}` | `nats.connect()`에 그대로 전달 (재접속, TLS 등) |
|
|
53
|
+
|
|
54
|
+
## Channels 규약과 다른 점
|
|
55
|
+
|
|
56
|
+
NATS는 저장 없는 at-most-once pub/sub이다. 이 레이어가 그 위에서 지키는 것과 못 지키는 것.
|
|
57
|
+
|
|
58
|
+
- **수신자가 먼저 있어야 한다.** 채널의 첫 `receive()`(또는 `new_channel()`) 전에 발행된 메시지는 사라진다. 컨슈머는 연결 시 구독하므로 일반 코드에는 영향이 없고, 임의 이름의 채널에 먼저 `send`하고 나중에 `receive`하는 패턴만 다르다.
|
|
59
|
+
- **`ChannelFull`은 발생하지 않는다.** 보내는 쪽은 상대 대기열을 모른다. 대신 받는 쪽이 넘치는 메시지를 버린다. `channels_redis`는 큐가 `capacity`를 넘으면 보내는 쪽에 이 예외를 던지므로, 그것을 잡던 코드는 여기서 아무 신호도 받지 못한다.
|
|
60
|
+
- **버퍼가 있는 곳이 다르다.** `channels_redis`는 메시지를 Redis 안에 `expiry`(기본 60초)까지 보관하므로 받는 쪽이 아직 없어도 나중에 받는다. 이 레이어의 버퍼는 **받는 프로세스의 로컬 mailbox**이고 구독이 생긴 뒤에만 존재한다. 즉 "버퍼가 없다"가 아니라 "버퍼가 브로커가 아니라 구독자 안에 있다"가 정확하다.
|
|
61
|
+
- **그룹 멤버십은 프로세스 안에 있다.** 프로세스가 죽으면 그 멤버십도 사라지므로 `group_expiry`는 형식상 유지된다.
|
|
62
|
+
- **연결은 이벤트 루프마다 하나**다. Django 시그널이나 뷰에서 `async_to_sync(layer.group_send)`를 불러도 된다.
|
|
63
|
+
|
|
64
|
+
## Core NATS만 쓴다 — JetStream을 쓰지 않는 이유
|
|
65
|
+
|
|
66
|
+
이 레이어는 Core NATS의 `publish`/`subscribe`만 쓴다. JetStream(스트림, 소비자, `.blk` 파일)을 만들지도 열지도 않고, 의존은 `nats-py` 하나다. 디스크에 아무것도 쓰지 않는다.
|
|
67
|
+
|
|
68
|
+
**Channels 채널 레이어에 JetStream이 필요 없기 때문이다.** Channels 스펙이 요구하는 전달 보장은 at-most-once이고, 그것은 Core NATS가 이미 주는 것이다. 영속 스트림을 얹으면 레이어가 보장하지 않는 것을 보장하는 것처럼 보이게 만들면서 운영 부담(스토리지, 보존 정책, 소비자 상태)만 늘어난다.
|
|
69
|
+
|
|
70
|
+
**Jepsen의 NATS 보고서는 이 레이어에 해당하지 않는다.** 2025-12 [Jepsen: NATS 2.12.1](https://jepsen.io/analyses/nats-2.12.1)이 `.blk` 파일의 단일 비트 오류로 승인된 쓰기 1,367,069건 중 679,153건(49.7%)이 사라지는 것을 보고했다. 이 레이어를 쓸지 판단할 때 자주 인용될 문서라 범위를 적어 둔다.
|
|
71
|
+
|
|
72
|
+
- **검증 대상은 JetStream뿐이다.** 보고서가 "We tested NATS JetStream"이라 밝히고, Core NATS는 "Regular NATS streams are allowed to drop messages"라며 범위에서 제외했다.
|
|
73
|
+
- **결함은 전부 디스크 영속성 계층의 것이다.** `.blk` 파일 손상(#7549), 스냅샷 파일 손상(#7556), 지연된 fsync(#7564), split-brain(#7567). 프로세스 크래시로 스트림이 통째로 사라지던 #6888은 2.10.23에서 고쳐졌다.
|
|
74
|
+
- **이 레이어에는 스트림도 `.blk` 파일도 없다.** 따라서 위 결함이 재현될 표면이 없다.
|
|
75
|
+
- **다만 "in-memory는 장애에 강하다"도 이 보고서의 결론이 아니다.** Jepsen은 memory storage 스트림도 Core NATS도 시험하지 않았다. 시험되지 않은 것은 안전이 입증된 것이 아니다. 이 레이어에 기대야 할 보장은 위 "Channels 규약과 다른 점"에 적힌 것, 그것뿐이다.
|
|
76
|
+
|
|
77
|
+
나중에 영속성이 필요해지면(재연결 중 놓친 메시지 재생 같은) 그때는 이 보고서가 정면으로 해당한다. 그런 기능은 Channels 레이어의 계약 밖이므로 여기가 아니라 애플리케이션에서 다룰 일이다.
|
|
78
|
+
|
|
79
|
+
## subject 규약
|
|
80
|
+
|
|
81
|
+
| subject | 의미 |
|
|
82
|
+
|---------|------|
|
|
83
|
+
| `<prefix>.pc.<process>` | 프로세스 전용 채널 `specific.<process>!<id>`로의 `send`. 전체 채널 이름은 NATS 헤더 `Channel`에 실리고, 받은 프로세스가 로컬에서 라우팅한다. 프로세스당 구독 하나 |
|
|
84
|
+
| `<prefix>.ch.<channel>` | `!`가 없는 일반 채널로의 `send`. 채널당 구독 하나 |
|
|
85
|
+
| `<prefix>.grp.<group>` | `group_send(group, message)`. 그룹에 멤버가 있는 프로세스마다 구독 하나 |
|
|
86
|
+
|
|
87
|
+
본문은 serializer로 직렬화한 Channels 메시지 dict다. 컨슈머 연결 하나의 비용은 로컬 대기열 하나이고 NATS 구독이 아니다. 이 규약만 지키면 Go로 만든 WebSocket 프런트가 Python 없이도 같은 그룹에 뿌릴 수 있다. 그때도 Django 쪽 코드는 바뀌지 않는다.
|
|
88
|
+
|
|
89
|
+
## Windows 운영
|
|
90
|
+
|
|
91
|
+
`nats-server.exe` 하나가 전부다. Windows 서비스로 올리는 방법.
|
|
92
|
+
|
|
93
|
+
1. [릴리스 zip](https://github.com/nats-io/nats-server/releases)을 `C:\nats\`에 푼다.
|
|
94
|
+
2. 설정 파일 `C:\nats\nats.conf`를 만든다. 외부에 열지 않고 토큰을 요구하는 최소 구성이다.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
listen: 127.0.0.1:4222
|
|
98
|
+
authorization { token: "긴-무작위-문자열" }
|
|
99
|
+
log_file: "C:\nats\nats.log"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
3. 서비스로 등록하고 시작한다 (관리자 PowerShell). nats-server는 Windows 서비스 제어를 직접 지원한다.
|
|
103
|
+
|
|
104
|
+
```powershell
|
|
105
|
+
sc.exe create nats-server binPath= "C:\nats\nats-server.exe -c C:\nats\nats.conf" start= auto
|
|
106
|
+
sc.exe start nats-server
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
4. Django 쪽은 URL에 토큰을 넣는다.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
CHANNEL_LAYERS = {
|
|
113
|
+
"default": {
|
|
114
|
+
"BACKEND": "channels_nats.NatsChannelLayer",
|
|
115
|
+
"CONFIG": {"servers": [f"nats://{os.environ['NATS_TOKEN']}@127.0.0.1:4222"]},
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
여러 Python 프로세스(daphne 등)는 같은 URL로 붙으면 한 레이어를 공유한다. SQLite를 쓰는 단일 서버라면 이것으로 멀티프로세스 구성이 끝난다. 상태 확인은 `sc.exe query nats-server`, 로그는 `nats.log`, 재시작은 `sc.exe stop`과 `start`다.
|
|
121
|
+
|
|
122
|
+
macOS와 Linux에서는 `brew services start nats-server` 또는 systemd 유닛에 같은 설정 파일을 쓴다.
|
|
123
|
+
|
|
124
|
+
## 벤치마크
|
|
125
|
+
|
|
126
|
+
`make bench`가 프로세스 P개에 멤버 채널 N개를 나눠 구독시키고 `group_send`를 M회 발행해, 발행에서 각 멤버의 `receive`까지의 지연과 처리량을 잰다. 결과는 `bench/results/`에 남는다. 아래는 macOS arm64, Python 3.12, nats-server 2.14.6에서 잰 값이다 (2026-09-08).
|
|
127
|
+
|
|
128
|
+
| 구성 | 전달 | p50 | p99 | 처리량 |
|
|
129
|
+
|------|-----:|----:|----:|-------:|
|
|
130
|
+
| 멤버 1,000, 프로세스 4, 발행 50회 | 50,000 / 50,000 | 0.83 ms | 1.46 ms | 92k/s (발행 간격 10 ms에 묶임) |
|
|
131
|
+
| 멤버 10,000, 프로세스 8, 발행 20회 | 200,000 / 200,000 | 4.3 ms | 16.5 ms | 924k/s |
|
|
132
|
+
|
|
133
|
+
참고로 InMemory 레이어(프로세스 하나)는 멤버 1,000의 `group_send`에 62 ms, 10,000에 10.7초가 걸린다. Python이 멤버 수만큼 돌기 때문이고, NATS에서는 그 일이 Go 서버로 넘어간다.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
make bench ARGS="--members 5000 --processes 8 --messages 50"
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 실전 확인
|
|
140
|
+
|
|
141
|
+
django-wireview의 테스트 프로젝트와 브라우저 E2E가 이 레이어 위에서 통과한다. daphne 4개를 NATS로 묶은 2,000 연결 실측(항목 5개 컴포넌트)은 다음과 같고, InMemory 레이어의 daphne 1개는 브로드캐스트 862 ms, 이벤트 3,013/s, 연결당 46 KB였다.
|
|
142
|
+
|
|
143
|
+
| channels-nats | 연결당 RSS | join/s | 이벤트/s | 브로드캐스트 |
|
|
144
|
+
|---|---:|---:|---:|---:|
|
|
145
|
+
| 0.1.0 채널당 구독 | 61.3 KB | 1,861 | 11,621 | 202 ms |
|
|
146
|
+
| 0.2.0 프로세스당 구독 | 55.3 KB | 2,167 | 11,758 | 143 ms |
|
|
147
|
+
|
|
148
|
+
결과 JSON은 `bench/results/wireview-nats-4proc-0.2.0.json`이다. 수치와 재현 명령은 [django-wireview의 설계 문서](https://github.com/itda-work/django-wireview/blob/main/docs/design/transport-abstraction.md)에 있다.
|
|
149
|
+
|
|
150
|
+
## 개발
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
uv sync --all-extras
|
|
154
|
+
NATS_SERVER=~/go/bin/nats-server uv run pytest # PATH에 있으면 환경변수 불필요
|
|
155
|
+
uv run ruff check . && uv run pyright
|
|
156
|
+
```
|
|
File without changes
|