Metadata-Version: 2.5
Name: s-replicationkit
Version: 0.1.0
Summary: «Доставка событий» экосистемы S-kits: монотонная последовательность и конверт, очередь конвертов с разделением временного и постоянного отказа, курсор, который двигается только после применения и хранится на диске, буфер повтора для возобновления после разрыва, уведомитель вместо опроса базы и удержание долгоживущего соединения.
Author: Dmitry
License: MIT
License-File: LICENSE
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# s-replicationkit — доставка событий между узлами

Кит родился из **волны 7 платформы китов** (Atlas #2718) и закрывает четыре
дыры синхронизации, найденные в коде Atlas и подтверждённые комментариями
самого этого кода.

| Дыра | Как было | Что в ките |
|---|---|---|
| 1. Курсор двигался по батчу целиком | пропущенное событие проезжалось курсором и больше не переспрашивалось — «потеря событий была НЕВИДИМА» | `stream.apply_stream` двигает курсор по непрерывной цепочке применённых, до первого пропущенного |
| 2. Отказ доставки не различался | невалидное навсегда событие гоняло пять попыток наравне с обрывом сети; обрыв сети на пятой попытке выбрасывал здоровое событие | `failures` + `queue.decide`: постоянный отказ уходит сразу, временный держится до порога |
| 3. Опрос базы вместо уведомителя | долгий опрос ходил в базу дважды в секунду — плата бралась за тишину | `notify.Notifier` + `notify.wait_for_events` |
| 4. Курсор — метка времени | пятьсот событий с одной меткой доставляли одно | `envelope.SequenceAllocator` — монотонный номер |

И пятое, ради чего всё затевалось: **поток, не теряющий события на разрыве**.
Кадр несёт номер (`id:`), клиент возвращается с `Last-Event-ID`, сервер отдаёт
хвост из `stream.ReplayBuffer` — а если хвост уже вытеснен, честно говорит об
этом (`truncated=True`), а не молчит.

## Состав

| Модуль | О чём |
|---|---|
| `replicationkit.envelope` | конверт доставки и монотонная последовательность вместо метки времени |
| `replicationkit.cursor` | курсор канала: в памяти, на диске, поверх чужого стора |
| `replicationkit.stream` | применение с продвижением курсора и буфер повтора хвоста |
| `replicationkit.queue` | судьба записи очереди после неудачной отправки |
| `replicationkit.filequeue` | очередь конвертов на диске (переехала из `telemetrykit.outbox`) |
| `replicationkit.failures` | временный отказ против постоянного |
| `replicationkit.notify` | уведомитель вместо опроса базы |
| `replicationkit.hold` | удержание долгоживущего канала: возврат после обрыва, стоп после отзыва |

Зависимостей нет — только stdlib. Доставка стоит под всеми, и кит, который
тянет за собой транспорт или ORM, навязывает их каждому потребителю.

## Как это выглядит

Применение с честным курсором:

```python
from replicationkit import FileCursorStore, apply_stream

report = apply_stream(envelopes, apply_one, cursor_store=FileCursorStore(path), channel="atlas")
if not report.complete:
    log.warning("отложено %s конвертов: %s", report.skipped, report.pending)
```

Отказ доставки, разобранный по существу:

```python
from replicationkit import decide

record.attempts += 1
verdict = decide(exc, record.attempts, permanent=(AccessRevoked,))
record.status = verdict.status        # 'failed' сразу — или 'pending' до порога
record.last_error = verdict.reason
```

Поток, который переживает разрыв:

```python
from replicationkit import Envelope, ReplayBuffer, SequenceAllocator

seq, buffer = SequenceAllocator(), ReplayBuffer(maxlen=512)
buffer.add(Envelope(seq=seq.next(), envelope_id=job_id, kind="job.status", payload=state))

tail, truncated = buffer.since(parse_cursor(request.headers.get("Last-Event-ID")))
```

## Переезды и переходники

`telemetrykit.outbox` остался как **переходник**: его публичный API
(`append` / `read_batch` / `remove` / `path`) не изменился, а механика теперь
живёт в `replicationkit.filequeue.FileEnvelopeQueue`. Причина переезда: очередь
исходящих конвертов — это не про телеметрию, через неё едет что угодно, и
второй такой механизм заводить нельзя. Чтение переменных окружения с префиксом
продукта осталось у переходника: кит доставки имени продукта не знает.

Образец удержания соединения (`hold_channel`) поднят из `netkit.hold` — там он написан
и обкатан на бою; сам `netkit` не тронут.

## Прогон

```bash
uv sync --group dev
uv run pytest -q
uv run ruff check .
uv run --with import-linter lint-imports
```
