Skip to content

Support BOLT v5 protocol and add client-side router pool#48

Open
maksbotan wants to merge 3 commits into
zmactep:masterfrom
maksbotan:maksbotan/bolt-5
Open

Support BOLT v5 protocol and add client-side router pool#48
maksbotan wants to merge 3 commits into
zmactep:masterfrom
maksbotan:maksbotan/bolt-5

Conversation

@maksbotan

@maksbotan maksbotan commented Mar 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR upgrades hasbolt from BOLT v3 to BOLT v5 (versions 5.0–5.8) as the default protocol, adding Neo4j 5.x support while maintaining backward compatibility with v3/v4 servers. It also introduces a client-side connection pool with cluster routing (RouterPool).

Version bump: 0.1.7.2 → 0.1.8.0

BOLT v5 protocol changes

  • Authentication flow: v5 uses a two-step flow — HELLO (without credentials) followed by a separate LOGON message, instead of inlining credentials in HELLO. On close, LOGOFF is sent before GOODBYE.
  • PULL/DISCARD with explicit count: v5 replaces PULL_ALL/DISCARD_ALL with PULL {n: -1}/DISCARD {n: -1} (parameterized variants).
  • Version negotiation: The handshake now stores the server-negotiated version in Pipe (previously assumed client == server version). versionAccepted allows any non-zero server response, enabling fallback from v5 to v3.
  • New request types: RequestLogon, RequestLogoff, RequestTelemetry, RequestPull, RequestDiscard, RequestRoute — with corresponding serialization instances and signature constants.
  • Version-gated features:
    • v5.2+: notification filtering (notifications_minimum_severity, notifications_disabled_categories)
    • v5.3+: bolt_agent in HELLO
    • v5.6+: renamed notifications_disabled_categoriesnotifications_disabled_classifications
  • Default config: BoltCfg now defaults to version 0x00070805 (v5.0–5.8 range proposal) and user agent hasbolt/1.8.

Graph type changes (Neo4j 5 compatibility)

  • Node, Relationship, and URelationship gain elementId fields (Text, empty string for v3 servers).
  • Deserialization handles both v3 (3/5/3 fields) and v5 (4/8/4 fields) structure shapes.

New BoltCfg fields

  • notifMinSeverity :: Maybe Text — notification severity filter
  • notifDisabledClass :: [Text] — disabled notification categories/classifications
  • database :: Maybe Text — target database name (sent in BEGIN, RUN, ROUTE extras)

Transactions

  • transactRead added — sends mode: "r" in BEGIN for read routing in clusters.
  • transact/transactRead now handle both BoltError (via catchError) and IO exceptions (via MonadCatch.onException) for rollback, requiring MonadCatch m constraint.
  • BEGIN/RUN now include notification filtering and database extras.

Client-side router pool (RouterPool)

New module Database.Bolt.Connection.RouterPool provides a connection pool for Neo4j clusters:

  • Topology discovery: Bootstraps via ROUTE message (BOLT v4.3+) to discover readers, writers, and routers.
  • Connection pooling: Per-server idle pipe lists with configurable max connections (rpcMaxPerServer, default 10) and idle timeout (rpcIdleTimeout, default 30s).
  • Load balancing: Least-connections selection with round-robin tie-breaking.
  • Pipe lifecycle: generalBracket (from exceptions package) ensures pipes are returned on success/app error and destroyed on connection error/async exception.
  • Background reaper: Periodically closes idle pipes past the timeout.
  • Routing table refresh: TTL-based, non-blocking (at most one thread refreshes; others use the stale table). On refresh, the pool reconciles: closes pipes to removed servers, creates empty pools for new servers.
  • Fallback on connect failure: If the best-ranked server is unreachable, the pool falls back through the ranked list, shifting the in-use reservation between servers.
  • API: connectRouterPool/closeRouterPool, runRouterPool/runRouterPoolRead (and *E variants returning Either), getRoutingTable.

Routing table parsing (RoutingTable)

New module Database.Bolt.Connection.RoutingTable:

  • parseRoutingTable — parses ROUTE response (supports both Bolt 4.3 top-level and 4.4+ nested rt key formats).
  • parseAddress — parses host:port and [ipv6]:port address strings.
  • isExpired — TTL-based expiry check.

PackStream fixes and additions

  • Unsigned size fields: All collection/text size unpacking changed from getInt8/getInt16be/getInt32be to getWord8/getWord16be/getWord32be. This fixes incorrect decoding of sizes >= 128 (e.g., a 200-character text was decoded as negative size with signed reads).
  • Bytes type: New Bytes ByteString constructor in Value, with BoltValue ByteString and IsValue ByteString instances. Supports PackStream bytes markers 0xCC/0xCD/0xCE.

Other changes

  • AuthToken Show instance now redacts credentials.
  • mkFailure uses safe M.lookup instead of partial (!) for extracting error code/message from failure responses.
  • BoltActionT derives MonadThrow/MonadCatch (via exceptions package).
  • close catches and ignores errors when sending LOGOFF/GOODBYE (prevents exceptions on already-broken connections).
  • catchError in pullKeys now re-throws non-RecordHasNoKey errors instead of swallowing them.
  • Renamed isNewVersionisV3 and added granular version checks (isV5, isV4_3, isV5_2, isV5_3, isV5_6).

New dependencies

  • exceptions >= 0.10 — for MonadMask/generalBracket in pool pipe lifecycle
  • time >= 1.9 — for routing table TTL/expiry
  • async >= 2.2 — for background reaper thread
  • containers lower bound raised to >= 0.6.0.1 (for nubOrd from Data.Containers.ListUtils)

Tests

All tests are pure (no Neo4j instance required), 113 total. New test groups:

  • Bolt v5: Node/Relationship/URelationship deserialization with v5 element IDs and v3-compat fallback; LOGON/LOGOFF/PULL/DISCARD structure roundtrips.
  • Bolt 5.2–5.8: Version checks using the real library functions (isV3, isV5, isV4_3, isV5_2, isV5_3, isV5_6), unsigned size field roundtrips (text >= 128 bytes, list >= 128 elements), TELEMETRY message, Bytes value pack/unpack (including bytes16 marker for 256+ bytes), BoltCfg default checks.
  • helloMap: v3 (credentials inline, routing ignored), v5.1 (no credentials, optional routing), v5.3 (bolt_agent included).
  • notifExtra: pre-v5.2 returns empty, v5.2 severity/disabled categories, v5.6 key rename to classifications, combined and empty cases.
  • ToStructure Request: Roundtrip through toStructurepackunpack for RequestLogon, RequestLogoff, RequestTelemetry, RequestPull, RequestDiscard, RequestRoute, RequestInit (v3/v5/v5+routing), RequestBegin.
  • isConnectionError: Classifies CannotReadChunk as connection error; ResponseError, RoutingTableUnavailable, etc. as non-connection errors.
  • reconcileState: Keeps existing servers (preserves in-use count), adds new servers with empty pools, removes stale servers returning their idle pipes, full server set replacement.
  • Routing: ROUTE message serialization, HELLO with routing context, BEGIN with db/mode extras.
  • Router: parseAddress (IPv4, IPv6, hostname with dots, error cases: port 0/65536/non-numeric/trailing text, colon-only, IPv6 without bracket-colon), parseRoutingTable (valid, missing servers, empty writers, nested rt key), isExpired (future, past, exact boundary).

@maksbotan maksbotan changed the title Maksbotan/bolt 5 Support BOLT v5 protocol and add client-side router pool Mar 29, 2026
@maksbotan maksbotan marked this pull request as ready for review March 29, 2026 23:45
@Gmihtt

Gmihtt commented Apr 1, 2026

Copy link
Copy Markdown

Что понравилось

  • Хорошо, что пароль перестал утекать в Show AuthToken.
  • Исправление на unsigned-длины в PackStream (getWord8/getWord16be/getWord32be вместо signed) выглядит правильным.
  • Поддержка Bytes и v5 element IDs — полезное и давно напрашивалось.
  • Вынесение роутинга/пула в отдельные модули и наличие pure-тестов на parseRoutingTable/reconcileState — хороший шаг.

Блокеры

1. Handshake / version negotiation сейчас не соответствует заявленной матрице совместимости

Где:

  • Connection_Type.hs:83-103
  • Connection_Pipe.hs:137-163
  • README.md:116-117
  • Spec.hs:332-335

Сейчас дефолтный version = 0x00070805 описан как диапазон 5.0–5.8, а в handshake/README отдельно обещан fallback до v3.

Проблема в том, что по BOLT range-encoding 0x00070805 — это 5.8..5.1, то есть без 5.0. При этом boltVersionProposal отправляет только один слот с этой версией и ещё три нуля:

boltVersionProposal bcfg = B.concat $ encodeStrict <$> [version bcfg, 0, 0 :: Word32, 0]

То есть клиент вообще не предлагает ни 5.0, ни 4.x, ни 3. Следовательно:

  • заявление про дефолтный диапазон 5.0–5.8 сейчас неверно;
  • заявление про fallback до v3 тоже неверно;
  • дефолтный RouterPool фактически не сможет договориться с 4.3/4.4, хотя PR и документация создают такое ожидание.

Отдельно: versionAccepted сейчас проверяет только server /= 0, т.е. принимает любой ненулевой ответ сервера, а не только одну из реально предложенных версий.

Что бы я предложил:

  • либо честно сузить поддержку в дефолте до 5.1–5.8 и убрать обещание fallback;
  • либо формировать реальный proposal из 4 слотов (например, 5.8..5.1, 5.0, 4.4, 3, либо другой явно поддерживаемый набор);
  • и сделать versionAccepted строгим: сервер должен вернуть только одну из реально предложенных версий.

Пока этого нет, основное обещание PR про backward compatibility выглядит сломанным на wire-level.

2. RouterPool сейчас небезопасен вместе с lazy query / queryP

Где:

  • Connection.hs:47-53
  • Connection.hs:98-116
  • Connection_RouterPool.hs:17-26
  • Connection_RouterPool.hs:261-272

query/queryP по-прежнему ленивые, а pullRecords использует unsafeInterleaveIO. При этом runPoolAction возвращает pipe в пул сразу после того, как action вернул Right _:

MC.ExitCaseSuccess (Right _) -> releasePipe rp addr pipe

Это означает, что такой код из примера модуля:

records <- runRouterPoolRead pool $ query "MATCH ..." mempty

возвращает ленивый список records, после чего pipe уже отдан обратно в пул. Следующий поток может взять тот же самый pipe и начать слать в него новые сообщения, пока предыдущий результат ещё не дочитан. На BOLT-соединении это прямой путь к interleaving/protocol corruption.

Это не теоретическая проблема — unsafe-паттерн уже зашит прямо в публичный пример использования.

Что бы я предложил:

  • либо явно запретить lazy API внутри RouterPool;
  • либо перевести примеры/документацию/хелперы пула на strict-only (query' / queryP');
  • либо менять дизайн так, чтобы pipe жил до полного потребления стрима (что уже сложнее и похоже потребует другого API).

В текущем виде я бы не мёржил: API документирует небезопасный сценарий.

3. close шлёт LOGOFF перед GOODBYE, и это выглядит как неверная семантика завершения соединения

Где:

  • Connection_Pipe.hs:52-60
  • Connection_Type.hs:180-185
  • Value_Helpers.hs:85-88

Сейчас закрытие делает вот это:

when (isV5 $ pipe_version pipe) $ makeIO (`flush` RequestLogoff) pipe
when (isV3 $ pipe_version pipe) $ makeIO (`flush` RequestGoodbye) pipe

То есть для 5.1+ клиент шлёт LOGOFF, не читает SUCCESS, и сразу шлёт GOODBYE.

По state machine BOLT 5.1+ LOGOFF переводит соединение из READY в AUTHENTICATION; после этого сервер ждёт LOGON, а не GOODBYE. Плюс сама спецификация handshake прямо говорит, что у Bolt нет формальной shutdown-процедуры, и TCP-close допустим сам по себе.

Поэтому текущая логика выглядит как минимум очень спорной, а скорее — просто неправильной:

  • LOGOFF — это деаутентификация / re-auth flow, а не нормальное закрытие сокета;
  • GOODBYE после LOGOFF, ещё и без ожидания SUCCESS на LOGOFF, выглядит как нарушение ожидаемой последовательности сообщений.

Что бы я предложил:

  • на close оставить GOODBYE (или вообще просто TCP close) и не использовать LOGOFF;
  • если LOGOFF зачем-то нужен отдельно, то это должен быть отдельный API с ожиданием SUCCESS, а не часть обычного close.

4. Поддержка BOLT 4.x / 5.0 ещё не доведена до конца: wire-format местами остаётся 5.1+/4.4-centric

4.1. PULL / DISCARD выбираются только для 5.1+, хотя нужны уже с 4.0

Где:

  • Connection.hs:67-69
  • Connection.hs:86-88
  • Value_Helpers.hs:85-88

Сейчас ветка выбирается через isV5, а isV5 = isV5_N 1, т.е. фактически это 5.1+, а не 5.0+.

В результате для 4.x и 5.0 код всё ещё шлёт DISCARD_ALL / PULL_ALL, хотя начиная с Bolt v4 это уже DISCARD / PULL с extra.

Это ломает как минимум заявленную поддержку 4.x / 5.0.

4.2. ROUTE всегда сериализуется как 4.4+ форма, но 4.3 ожидает другой третий аргумент

Где:

  • Connection_Instances.hs:40
  • Connection_RouterPool.hs:181-182
  • Connection_RouterPool.hs:489
  • Spec.hs:352-369, 638-640

RequestRoute всегда кодируется так:

Structure sigRoute [M routeContext, L (map T routeBookmarks), M routeExtra]

То есть третий аргумент всегда extra::Dictionary.

Но для Bolt 4.3 ROUTE использует старую форму, где третий аргумент — ещё не extra-словарь, а отдельный db-параметр. Перенос db внутрь extra произошёл только в 4.4.

Иными словами, connectRouterPool сейчас заявляет поддержку v4.3+, но на ровно 4.3 wire-format у ROUTE будет неправильным.

4.3. routing в HELLO добавляется только для 5.1+, хотя появился ещё в 4.1

Где:

  • Connection_Instances.hs:86-96
  • Connection_Pipe.hs:39-40

connectWithRouting по комменту должен "passes routing context in HELLO", но фактически helloMap добавляет routing только в ветке isV5 serverVersion, т.е. снова только для 5.1+.

Для 4.1–5.0 routing context в HELLO сейчас теряется, хотя именно там он и был введён.

Итог по п.4:

У меня складывается ощущение, что поддержка 5.1+ выровнена лучше, чем поддержка 4.x/5.0, но в PR/README это не проговорено. Пока не будут закрыты все три подпункта, я бы не говорил, что драйвер действительно поддерживает Neo4j 4–5 на wire-level без оговорок.

Неблокирующие, но важные замечания

5. routingContext ломает IPv6 bootstrap-адреса

Где:

  • Connection_Instances.hs:118-120
  • Connection_RoutingTable.hs:85-106
  • Spec.hs:430, 814

Сейчас адрес формируется простым host <> ":" <> port. Для IPv6 literal это даст что-то вроде:

::1:7687

а парсер и тесты ожидают форму:

[::1]:7687

Нужно либо bracket'ить IPv6 host при построении routing context, либо ввести отдельный helper для нормализации адреса.

6. Тестов стало сильно больше, но самые рискованные места всё ещё без покрытия

Сейчас очень много хороших pure/serialization тестов, но не хватает тестов именно на самые опасные места этого PR:

  • raw handshake proposal bytes;
  • negative/compat tests на 5.0, 4.4, 4.3, 3;
  • ROUTE serialization отдельно для 4.3 и 4.4+;
  • сценарий с runRouterPool(Read) + lazy query;
  • поведение close для 5.1+.

Именно там у меня возникли основные замечания выше.

7. В комментариях/доках есть несколько внутренних противоречий

Например:

  • RouterPool в caveats пишет, что нужен BOLT v5+, а код проверяет v4.3+;
  • комментарий к isV5 говорит v5.0+, но реализация — это 5.1+;
  • комментарий к RequestLogoff уже кодирует спорное предположение, что он должен идти "before GOODBYE on close";
  • README обещает fallback до v3, которого сейчас нет.

Это всё не самое страшное по отдельности, но вместе сильно усложняет понимание реальной матрицы поддержки.

Итог

По направлению PR хороший и полезный, но в текущем виде я бы не мёржил его до исправления как минимум пп. 1–4. Они не про стиль и не про taste — это именно вопросы protocol compatibility / lifecycle safety.

Если коротко, то мой главный вывод такой:

сейчас PR выглядит как "поддержка BOLT 5.1+ + часть 4.x/5.0", но документация и дефолтная конфигурация обещают заметно больше, чем реально обеспечивается на wire-level.

Для сверки я ориентировался на официальную документацию Neo4j / Bolt:

@maksbotan

Copy link
Copy Markdown
Collaborator Author

Thanks for the review!

  1. Handshake / version negotiation сейчас не соответствует заявленной матрице совместимости

You are right. I've limited supported versions to Bolt v3 and Bolt v5.6 - v5.8. Version 4 and
intermediate versions of v5 are not supported now.

hasbolt will not accept connections to unsupported servers even if user tries to override version
in BoltCfg.

Handshake process was simplified to get rid of checks for various 5.x variants.

  1. RouterPool сейчас небезопасен вместе с lazy query / queryP

All RouterPool-related reexports were removed from Database.Bolt.Lazy module. A warning was put
into README and module's haddock to stress that lazy mode is incompatible with RouterPool.

  1. close шлёт LOGOFF перед GOODBYE, и это выглядит как неверная семантика завершения соединения

Fixed. Now we send only GOODBYE.

  1. Поддержка BOLT 4.x / 5.0 ещё не доведена до конца: wire-format местами остаётся 5.1+/4.4-centric

Not applicable anymore, support for 4.x was explicitly disabled.

  1. routingContext ломает IPv6 bootstrap-адреса

I've decided to leave it as is for now, let's look into it again someone actually tries to use IPv6
addresses.

  1. Тестов стало сильно больше, но самые рискованные места всё ещё без покрытия

Removed tests for now removed code. The rest is not important IMO.

  1. В комментариях/доках есть несколько внутренних противоречий

Docs and comments were fixed.

-- On connection error or exception: pipe is destroyed.
runPoolAction :: (MonadIO m, MonadMask m) => RouterPool -> AccessMode -> BoltActionT m a -> m (Either BoltError a)
runPoolAction rp mode action = do
(result, _) <- MC.generalBracket

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"
E-варианты обещают вернуть Either, но acquirePipe выполняется до runE внутри generalBracket. Ошибки выбора сервера, отсутствия reader/writer или подключения вылетят как исключения, а не как Left BoltError. Для пользователя это ломает ожидаемый контракт runRouterPoolE/runRouterPoolReadE.
"

_ -> do
-- No idle pipe - reserve a slot on the best server, return full
-- ranked list so caller can fall back to others on connect failure.
let sp = M.findWithDefault emptyServerPool best (psServers ps)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"
При отсутствии idle-pipe код просто увеличивает spInUse и открывает новое соединение, не проверяя rpcMaxPerServer. При всплеске параллельных запросов можно открыть сильно больше лимита и перегрузить Neo4j; лишние соединения закрываются только после возврата.
"

pure pipe
connectWithRouting' :: MonadPipe m => BoltCfg -> m Pipe
connectWithRouting' bcfg = do
conn <- C.connect (secure bcfg) (host bcfg) (fromIntegral $ port bcfg) (socketTimeout bcfg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"
После C.connect вызывается handshake без bracket/onException. Если handshake упадёт на версии, auth или чтении ответа, TCP-соединение уже создано, но не закрывается. В RouterPool это особенно заметно: повторные refresh/fallback-попытки могут копить незакрытые соединения.
"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants