server

package
v0.11.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: MIT Imports: 36 Imported by: 0

Documentation

Overview

Package server implements the Bolt v5 TCP server for the GoGraph Cypher engine. It handles connection acceptance, Bolt protocol negotiation, session lifecycle, and authentication.

Concurrency

Server is safe for concurrent use by multiple goroutines. Session and State are NOT safe for concurrent use; each connection owns exactly one Session.

Index

Examples

Constants

View Source
const (

	// DefaultMaxInFlightPerConnection is the default value applied to
	// Options.MaxInFlightPerConnection when the caller leaves it at
	// zero. The count tracks all Result cursors appended to the
	// in-progress explicit transaction since BEGIN (both open and
	// already-drained), so it bounds the total number of RUN statements
	// a client may issue without committing. The default of 1024 allows
	// any legitimate workload while still bounding pathological
	// RUN-loop attacks that grow tx.results without bound. Operators
	// that need a stricter limit may lower this value explicitly.
	DefaultMaxInFlightPerConnection = 1024

	// DefaultConnTimeout is the default value applied to Options.ConnTimeout
	// when the caller leaves it at zero. It is the per-message idle deadline
	// applied throughout the post-handshake message loop: the server resets it
	// before each read, so it bounds the time a connection may sit silent
	// between messages, not the total session duration. A non-zero default is
	// mandatory: with no deadline a client that completes the handshake but then
	// stops sending bytes would hold its connection slot and goroutine forever,
	// a Slowloris-style denial of service. The default of 30 s is generous
	// enough not to disturb a legitimate interactive session pausing between
	// queries while still reclaiming abandoned connections. Operators may set a
	// larger value for long-lived idle sessions or a smaller one to reclaim
	// connections more aggressively.
	DefaultConnTimeout = 30 * time.Second

	// DefaultTxTimeout is the default value applied to Options.DefaultTxTimeout
	// when the caller leaves it at zero. It bounds an explicit transaction
	// (opened by BEGIN) when the client supplies no tx_timeout of its own. A
	// finite default is mandatory: an explicit transaction holds the engine's
	// NO serialisation from BEGIN until COMMIT/ROLLBACK (rmp #2305), so a client
	// that issues BEGIN and then stalls — never sending COMMIT, ROLLBACK, or even
	// RESET — would otherwise block every other writer on the server forever, a
	// liveness denial of service (#1302). The default of 30 s is generous enough
	// not to disturb a legitimate interactive transaction while still guaranteeing
	// the global write lock is reclaimed if a transaction is abandoned. Operators
	// may set a larger value for long-lived batch transactions or a smaller one
	// to reclaim the writer lock more aggressively; the per-statement
	// MaxStatementTimeout, when set, additionally clamps it.
	DefaultTxTimeout = 30 * time.Second

	// DefaultMaxTxIdleTime is the value applied to Options.MaxTxIdleTime when the
	// caller leaves it at zero. It bounds how long an OPEN explicit transaction
	// may go without the client sending a message, which is a different and much
	// tighter bound than DefaultTxTimeout: that one caps a transaction's total
	// life, however busy, while this one reclaims one that has been ABANDONED.
	//
	// A finite default is mandatory, and 5 s rather than 30 s because of what the
	// round-3 audit demonstrated: one authenticated client sends BEGIN and stops
	// talking, and because an open transaction held the global visibility
	// barrier, a 4.7 ms read on every other connection became 30.001 s —
	// the full DefaultTxTimeout — followed by a hard TransactionTimedOut, and it
	// was repeatable indefinitely (rmp #2175). The total-lifetime bound cannot fix
	// that on its own: lowering it shortens the outage but also kills legitimate
	// long transactions, whereas an idle bound distinguishes the two cases, since
	// a working client sends messages.
	//
	// # Reviewed after rmp #2305, and KEPT at 5 s
	//
	// The outage above is GONE. An open transaction no longer holds the visibility
	// barrier or any writer serialisation, so an abandoned one blocks nobody: the
	// gates in bolt/server's e2e_concurrent_write_tx_test.go assert exactly that
	// against the official driver. The original justification for a bound this tight
	// therefore no longer applies, and the honest question is whether to relax it.
	//
	// It stays, because what an abandoned transaction now costs is still unbounded,
	// just in a different resource: it pins the reclamation horizon, so no version it
	// could still read is freed while it lives, and it occupies one of the horizon's
	// fixed number of slots. An availability failure became a memory-and-slot failure;
	// neither is acceptable without a bound, and 5 s remains far longer than any
	// working client needs between the messages of one transaction.
	//
	// What DID change is the severity of setting it high. Before rmp #2305 a large
	// MaxTxIdleTime was an availability risk; now it is a memory-growth risk. That is
	// why the concurrency gates can safely raise it to ten minutes to remove the
	// reaper's interference with what they measure — a thing that would have been
	// reckless on the previous build.
	//
	// 5 s is far longer than any client needs between the messages of one
	// transaction — a driver pipelines them — and short enough that an abandoned
	// transaction is reclaimed before it is felt as an outage. Operators driving
	// transactions from an interactive prompt may raise it.
	DefaultMaxTxIdleTime = 5 * time.Second

	// DefaultMaxOpenTxPerPrincipal is the value applied to
	// Options.MaxOpenTxPerPrincipal when the caller leaves it at zero. It caps
	// how many explicit transactions one authenticated principal may hold open at
	// once, across all of its connections.
	//
	// # It binds on WRITE transactions too as of rmp #2305
	//
	// This note used to say the bound could never be the binding constraint for
	// write transactions, because one held the engine's writer serialisation for its
	// whole life and the engine therefore capped concurrently-open write
	// transactions at ONE server-wide. rmp #2305 retired that hold. Write
	// transactions now overlap freely, so this is a REAL limit for them, and a
	// client that opens more than the default 16 at once is refused with
	// LimitExceeded. Two goroutine-leak tests in this package had to raise it for
	// exactly that reason.
	//
	// A READ transaction (BEGIN with mode "r") has always been concurrent and
	// unbounded per principal without this; that is what it originally capped, along
	// with the session and cursor state each one holds — and, since rmp #2307, the
	// MVCC read snapshot each one pins for its lifetime, which holds the reclamation
	// horizon back until the handle finishes. A write transaction pins the horizon
	// the same way since rmp #2305, so both modes now cost the same thing.
	//
	// The count is of OPEN transactions, not of BEGINs waiting to be admitted: a
	// burst of concurrent BEGINs from one principal is bounded by MaxConnections,
	// not by this. Counting waiting BEGINs here would reject legitimate concurrent
	// traffic.
	//
	// # Why 2048, and what it gives up (rmp #2419)
	//
	// It was 16, defended here on the grounds that "a pool with more than sixteen
	// connections per principal should say so explicitly". The 2026-08-11
	// concurrency assessment recorded the consequence as finding F2: CLAUDE.md
	// publishes 1, 8, 64, 256 and 1024 goroutines as the levels this module
	// measures and reports at, and a single principal could not reach them through
	// explicit transactions without overriding this first. Every harness that got
	// there had already had to — bench/soak with 1200, bench/comparison/ggserver
	// with a flag — which is the shape of a default disagreeing with a published
	// contract rather than of two harnesses being unusual.
	//
	// 2048 is above the highest published level, so the default configuration now
	// reaches the concurrency the module publishes. Note what that means in
	// practice: a connection holds at most one open transaction and
	// [Options.MaxConnections] defaults to 1024, so under the default
	// configuration this quota CANNOT BIND — the connection ceiling is reached
	// first, and it is the connection ceiling that bounds the resource. This
	// quota binds again only for an operator who raises MaxConnections above
	// 2048, and it remains what isolates one principal from another.
	//
	// THE COST, stated rather than glossed: every open transaction pins an MVCC
	// read snapshot and holds the reclamation horizon back for its lifetime
	// (rmp #2305, #2307), so a higher ceiling is a weaker bound on that resource.
	// What limits the damage is [DefaultMaxTxIdleTime]: an abandoned transaction
	// is reclaimed after 5 s rather than held until the client disconnects. An
	// embedder that wants the old tight bound sets Options.MaxOpenTxPerPrincipal
	// explicitly, which is now the only way to get it.
	//
	// Set a negative value to disable enforcement — which is a deliberate,
	// visible choice at the call site, not something reachable by accident.
	DefaultMaxOpenTxPerPrincipal = 2048

	// DefaultDatabaseName is the value applied to Options.DatabaseName when the
	// caller leaves it empty, and the name reported in the `db` field of result
	// metadata for a client that selected no database.
	//
	// It is "neo4j" because that is the database name every Bolt client assumes
	// when it is given none — the driver's own default, and what cypher-shell and
	// the Neo4j Browser display. Reporting it is a compatibility choice about a
	// label, not a claim about the product: the server identifies itself as
	// GoGraph in the `server` and `bolt_agent` fields of the HELLO response.
	// Operators serving a differently named graph may override it.
	//
	// GoGraph serves one graph per server, so the name selects nothing. An
	// unknown name from a client is echoed rather than rejected: Neo4j answers a
	// missing database with Neo.ClientError.Database.DatabaseNotFound, which
	// would be the stricter behaviour, but rejecting a name the server has always
	// accepted would break existing embedders and is a separate decision from
	// reporting the field at all.
	DefaultDatabaseName = "neo4j"

	// DefaultStatementTimeout is the default value applied to
	// Options.DefaultStatementTimeout when the caller leaves it at zero. It
	// bounds an AUTOCOMMIT statement (a bare RUN outside an explicit
	// transaction) when the client supplies no per-statement timeout of its
	// own. A finite default is mandatory for the same reason it is for explicit
	// transactions: an authenticated client can submit a statement whose runtime
	// is super-linear in the graph size yet whose result collapses to a single
	// row (a disconnected multi-pattern Cartesian product such as
	// `MATCH (a),(b),(c),(d),(e) RETURN count(*)`), so the result-row / byte caps
	// never fire and the statement pins a CPU core indefinitely. Explicit
	// transactions already receive DefaultTxTimeout unconditionally; without a
	// symmetric floor here, autocommit RUN was the sole unbounded-runtime path
	// under a default server configuration (#1828). The default of 30 s matches
	// DefaultTxTimeout; operators may set a larger value for long-running
	// analytical statements. A client-supplied `timeout` takes precedence, and
	// MaxStatementTimeout, when set, additionally clamps the effective value.
	DefaultStatementTimeout = 30 * time.Second

	// DefaultHandshakeTimeout is the deadline that bounds the unauthenticated
	// version-negotiation handshake — the cheapest phase for an attacker to
	// abuse, since it requires no valid protocol bytes (a client may open a
	// socket, send a single byte, and otherwise stall). The deadline is applied
	// to the connection before [proto.Negotiate] and cleared on success so it
	// never bleeds into normal operation. It is deliberately shorter than
	// DefaultConnTimeout: a legitimate client sends its 20-byte handshake
	// immediately, so 10 s is ample, while a stalled handshake is reclaimed
	// promptly. The handshake bound is fixed (not configurable via Options) to
	// keep the Options struct small; the package var handshakeTimeout is seeded
	// from this const and overridable only by tests.
	DefaultHandshakeTimeout = 10 * time.Second
)
View Source
const DefaultMaxInboundDecodeBytes int64 = 1 << 30 // 1 GiB

DefaultMaxInboundDecodeBytes is the engine-wide inbound-decode ceiling applied when Options.MaxInboundDecodeBytes is left at zero AND the process has no Go soft memory limit to derive one from.

It exists because the GOMEMLIMIT derivation below silently produced NO ceiling in the commonest deployment. An unset GOMEMLIMIT is the Go runtime's default — debug.SetMemoryLimit(-1) then reports math.MaxInt64 — so the documented "engine-wide inbound-memory ceiling" was inert unless the operator had separately set a memory limit, and the real bound was MaxConnections times the per-connection limits: 1024 x 16 MiB of reassembly buffers plus 1024 x 128 MiB of decoded collections. That allocation is reachable PRE-AUTHENTICATION, because a HELLO must be decoded before it can be authenticated. The bounded-resources mandate requires an explicit finite upper bound, and the existence of MaxInboundDecodeBytesUnlimited settles that zero was never meant to mean unlimited: an opt-out sentinel would be redundant if it did.

The value is 1 GiB, matching github.com/FlavioCFOliveira/GoGraph/cypher.DefaultMaxResultBytes so the module's finite defaults share one scale. It is 64x the largest single message a client may send (proto.DefaultMaxMessageBytes, 16 MiB) and 8x the worst-case decoded size of one message, so it admits several concurrent large-message decodes while bounding a hostile fleet's aggregate to something any production host survives. A deployment that sets GOMEMLIMIT keeps the derived one-eighth fraction and is unaffected.

View Source
const MaxInboundDecodeBytesUnlimited int64 = -1

MaxInboundDecodeBytesUnlimited is the explicit opt-out sentinel for Options.MaxInboundDecodeBytes: set the field to this value to disable the engine-wide inbound-decode ceiling entirely. It is distinct from the zero value, which selects the GOMEMLIMIT-derived default.

Variables

View Source
var (
	// ErrAuthFailed is returned when credentials are invalid.
	ErrAuthFailed = errors.New("bolt: authentication failed")

	// ErrSchemeUnknown is returned when the auth scheme is not supported.
	ErrSchemeUnknown = errors.New("bolt: unknown auth scheme")
)

Common auth errors.

View Source
var ErrInvalidTransition = errors.New("bolt: invalid state transition")

ErrInvalidTransition is returned by Transition when the given message type is not permitted in the current state.

View Source
var ErrNoAuthHandler = errors.New("bolt: no auth handler configured; set Options.Auth to a real AuthHandler, or to NoAuthHandler{} to run without authentication")

ErrNoAuthHandler is returned by NewServer when Options.Auth is nil. The server is secure-by-default: running without authentication must be an explicit opt-in, never an accidental default. Set Options.Auth to a real AuthHandler to require credentials, or set Options.Auth to a NoAuthHandler{} value to run the open-door handler on purpose (development and testing only).

View Source
var ErrNoSuchTransaction = fmt.Errorf("bolt: no such open transaction")

ErrNoSuchTransaction is returned by Server.TerminateTransaction for an ID that is not open — either never seen, or already ended between a listing and the termination call.

Functions

func ConstantTimeValidate

func ConstantTimeValidate(wantPrincipal, wantCredentials string) func(principal, credentials string) error

ConstantTimeValidate returns a Validate function that accepts only the given principal and credentials, using crypto/subtle.ConstantTimeCompare for both comparisons. The comparison time is independent of the values being compared, eliminating timing side-channels.

Example:

handler := server.BasicAuthHandler{
    Validate: server.ConstantTimeValidate("alice", "correct-horse-battery-staple"),
}

func DefaultTLSConfig

func DefaultTLSConfig() *tls.Config

DefaultTLSConfig returns a hardened baseline tls.Config that operators should use as the STARTING POINT for the server's transport security.

The configuration sets a TLS 1.2 floor and a modern, AEAD-only cipher list for the TLS 1.2 handshake; TLS 1.3 is negotiated automatically when both peers support it (TLS 1.3 cipher suites are fixed by the Go runtime and are always safe, so they are not — and cannot be — listed here). No MaxVersion is set, so a 1.3-capable client always upgrades to 1.3.

The returned config is INCOMPLETE on its own: it carries no certificate. Callers MUST populate it with their own server identity before use, by setting one of:

Then pass the result in Options.TLSConfig.

The server does NOT impose this baseline automatically. It wraps whatever Options.TLSConfig the operator supplies verbatim: passing a nil TLSConfig keeps the existing behaviour of running PLAINTEXT TCP (no TLS at all). DefaultTLSConfig only provides and documents a safe default; it never overrides an operator-supplied config, so embedders are never surprised.

A fresh, independent config is returned on every call (no shared mutable global), so callers may freely mutate the result — adding Certificates, GetCertificate, client-auth policy, etc. — without aliasing another caller's configuration.

func ExtractBookmarks

func ExtractBookmarks(extra map[string]packstream.Value) []string

ExtractBookmarks returns the bookmark list from RUN/BEGIN extra metadata. It reads the "bookmarks" key, which may be a []packstream.Value of strings. Returns nil (not an error) when the key is absent or the value is not a list.

func FailureCode

func FailureCode(err error) string

FailureCode returns the Neo4j-style dot-delimited error code for err. Falls back to "Neo.DatabaseError.General.UnknownError" for unrecognised errors. The lookup uses errors.As and errors.Is so wrapped errors are matched correctly.

func NextBookmark

func NextBookmark() string

NextBookmark generates a new bookmark string for a committed transaction. The format is "FB:kXXXXXX" where XXXXXX is a monotonically increasing counter expressed as a zero-padded 8-digit hexadecimal value.

NextBookmark is safe for concurrent use.

func RoutingTable

func RoutingTable(addr string) map[string]packstream.Value

RoutingTable returns the single-host routing table for the server at addr. The TTL is hardcoded to 300 seconds. All three roles (WRITE, READ, ROUTE) point to the same single-host address.

The returned map matches the Bolt v5 routing table format expected inside a SUCCESS metadata "rt" key.

Types

type AuthHandler

type AuthHandler interface {
	// Authenticate validates the auth scheme, principal, and credentials.
	// On success it returns an Identity; on failure it returns a non-nil error.
	// Returning ErrAuthFailed causes the server to send a Failure with code
	// "Neo.ClientError.Security.Unauthorized". Returning ErrSchemeUnknown
	// causes a Failure with code "Neo.ClientError.Security.AuthProviderFailed".
	Authenticate(scheme, principal, credentials string) (Identity, error)
}

AuthHandler is the pluggable authentication interface. Implementations must be safe for concurrent use.

type BasicAuthHandler

type BasicAuthHandler struct {
	// Validate is called with the principal and credentials from the client.
	// It must return nil on success and a non-nil error on failure.
	// See the type-level documentation for timing side-channel guidance.
	Validate func(principal, credentials string) error
}

BasicAuthHandler validates credentials by delegating to a caller-supplied Validate function. The Validate function must return nil on success and a non-nil error (typically ErrAuthFailed) on failure.

Timing side-channels

Validate is called with the raw credential string from the client. If the implementation compares credentials with == or strings.Equal, an attacker can infer the correct value by measuring response latency (timing side-channel). Always use ConstantTimeValidate or crypto/subtle.ConstantTimeCompare for credential comparison:

handler := BasicAuthHandler{
    Validate: ConstantTimeValidate("alice", "correct-horse-battery-staple"),
}

Do not add rate-limiting or account-lockout logic inside Validate; place it in a middleware wrapping the AuthHandler instead so the Bolt server remains stateless per-connection.

BasicAuthHandler is safe for concurrent use as long as Validate is.

func (BasicAuthHandler) Authenticate

func (h BasicAuthHandler) Authenticate(scheme, principal, credentials string) (Identity, error)

Authenticate implements AuthHandler. It accepts only the "basic" scheme; any other scheme returns ErrSchemeUnknown. It calls h.Validate with the principal and credentials; if Validate returns a non-nil error, Authenticate returns ErrAuthFailed.

type CertReloader

type CertReloader struct {
	// contains filtered or unexported fields
}

CertReloader watches a (certificate, key) PEM file pair on disk and serves the most recent successfully loaded pair via the CertReloader.GetCertificate hook installable on tls.Config.GetCertificate.

The intent is operational: rotate the server's TLS material (e.g. cert-manager / Let's Encrypt) without restarting the Bolt server. The previous certificate stays in service until the new pair is fully validated and only then is the swap performed atomically via sync/atomic.Pointer. A reload that fails to parse leaves the live certificate untouched and surfaces the error via the provided OnError callback (or via stderr when nil).

CertReloader is safe for concurrent use; the hot path is a single atomic.Pointer.Load.

func NewCertReloader

func NewCertReloader(certPath, keyPath string, onError func(error)) (*CertReloader, error)

NewCertReloader loads the certificate + key from disk and returns a CertReloader holding the result. The initial load is mandatory: if the files cannot be read or parsed, NewCertReloader returns the error and the caller MUST fail fast (do not start the server with a broken TLS config).

onError is invoked when a later reload (triggered by Reload or by the optional Watch goroutine) fails to parse the new pair. A nil onError defaults to printing to stderr via fmt.Fprintln.

func (*CertReloader) GetCertificate

func (r *CertReloader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error)

GetCertificate is the hook to install on tls.Config.GetCertificate. It returns the most recently loaded certificate. The signature matches the standard library's expectation so callers can do:

cfg := &tls.Config{GetCertificate: reloader.GetCertificate}

The returned *tls.Certificate is shared across all concurrent handshakes; callers must NOT mutate the returned value.

func (*CertReloader) Reload

func (r *CertReloader) Reload() error

Reload re-reads the certificate + key from disk and atomically swaps the live certificate when the parse succeeds. A parse failure leaves the live certificate untouched and returns the error so the caller (or the OnError callback installed via NewCertReloader) can record the incident.

func (*CertReloader) Watch

func (r *CertReloader) Watch(interval time.Duration, stop <-chan struct{})

Watch starts a background goroutine that polls the certificate and key files every interval and calls Reload when either has a fresh mtime. The goroutine exits when stop is closed. Watch returns immediately; pair it with sync.WaitGroup if the caller wants to block on shutdown.

Common usage:

stop := make(chan struct{})
go reloader.Watch(30*time.Second, stop)
defer close(stop)

Errors from Reload are surfaced via the onError callback installed at construction time; Watch itself never returns an error.

type Identity

type Identity struct {
	// Principal is the authenticated username or identifier.
	Principal string
}

Identity carries the authenticated principal's metadata after a successful authentication exchange.

type NoAuthHandler

type NoAuthHandler struct{}

NoAuthHandler accepts any credentials without validation. Suitable for development and testing only.

Because it admits every client, NoAuthHandler is never installed by default: the server is secure-by-default. To run without authentication an embedder must opt in explicitly by setting Options.Auth to a NoAuthHandler{} value. The explicit value is itself the opt-in — self-documenting at the call site and impossible to set by accident — and NewServer logs a loud warning when it sees one. Constructing a server with a nil Options.Auth fails closed with ErrNoAuthHandler. Never expose a NoAuthHandler-backed server on an untrusted network.

NoAuthHandler is safe for concurrent use.

func (NoAuthHandler) Authenticate

func (NoAuthHandler) Authenticate(_, principal, _ string) (Identity, error)

Authenticate implements AuthHandler. It always returns an Identity with the given principal and a nil error.

type Options

type Options struct {
	// Auth is the authentication handler invoked during HELLO/LOGON. It is
	// the security boundary of the server: every client must satisfy it
	// before any Cypher statement executes.
	//
	// Auth must be set; leave it nil and [NewServer] returns
	// [ErrNoAuthHandler]. The server is secure-by-default: a nil Auth is NOT
	// silently replaced with an open, accept-everyone handler, so a careless
	// embedder writing Options{} cannot accidentally expose an
	// unauthenticated server. To enforce credentials, set Auth to a real
	// [AuthHandler] such as [BasicAuthHandler]. To run without authentication
	// (development or testing only) set Auth: [NoAuthHandler]{} explicitly:
	// the explicit NoAuthHandler value is itself the opt-in, it is
	// self-documenting at the call site, and it is impossible to set by
	// accident. In that case [NewServer] still emits a loud warning.
	Auth AuthHandler

	// Closer, when non-nil, is the store-level teardown owner for the
	// durability stack backing this server's engine — typically a
	// *[github.com/FlavioCFOliveira/GoGraph/store.DB] bundling the WAL writer
	// and the background checkpointer. The server closes it AFTER it has
	// drained every active connection, so it runs the one crash-safe teardown
	// order (stop the checkpoint goroutine, then close the WAL) only once no
	// in-flight transaction can still be writing. Both documented stop
	// mechanisms reach that teardown: [Server.Shutdown] closes it on its
	// drain-success branch, and [Server.Serve] closes it on its own exit path
	// once its connection drain completes (e.g. when the Serve context is
	// cancelled). The close is guarded by a [sync.Once] inside the server, so
	// the closer's Close runs exactly once regardless of which path wins or
	// whether both run; it need not be idempotent itself. Leave it nil for a
	// store-less engine or when the embedder tears the durability stack down
	// itself; the server then closes nothing beyond its connections.
	Closer io.Closer

	// TLSConfig, when non-nil, wraps accepted connections with TLS using
	// the given configuration verbatim. nil means plain TCP (no TLS).
	//
	// The server applies no MinVersion or cipher policy of its own: whatever
	// config is supplied here is used as-is. To start from a hardened baseline
	// (TLS 1.2 floor, modern AEAD/ECDHE cipher list), begin with
	// [DefaultTLSConfig] and add your own Certificates or GetCertificate before
	// assigning it here.
	TLSConfig *tls.Config

	// Logger is the structured logger for server events. When nil, the
	// default slog handler is used.
	Logger *slog.Logger

	// DatabaseName is the name this server reports as the database serving a
	// result, in the `db` field of the RUN and terminal PULL/DISCARD SUCCESS
	// metadata. Empty defaults to [DefaultDatabaseName].
	//
	// GoGraph serves exactly one graph per server, so this is a label rather
	// than a selector: a client that names a database in its session config has
	// that name echoed back (so its own bookkeeping stays consistent), and a
	// client that names none is told this value. The name is not validated and
	// an unknown name is not rejected — see [DefaultDatabaseName].
	//
	// Sending it at all matters because the field is not optional in practice:
	// the official neo4j-go-driver returns a nil DatabaseInfo from
	// ResultSummary.Database() when `db` is absent, so the idiomatic
	// summary.Database().Name() panics with a nil dereference inside the driver
	// (rmp #2172).
	DatabaseName string

	// MaxTxIdleTime bounds how long an OPEN explicit transaction may go without
	// the client sending a message, after which it is rolled back and the writer
	// serialisation and visibility barrier are released. Zero or negative defaults
	// to [DefaultMaxTxIdleTime]; there is no way to disable it, because an
	// unbounded idle transaction is the outage the round-3 audit demonstrated.
	//
	// This is NOT DefaultTxTimeout. That bounds a transaction's total life however
	// active it is; this reclaims one that has stopped talking. A busy transaction
	// resets it on every message and is limited only by the total bound.
	MaxTxIdleTime time.Duration

	// MaxOpenTxPerPrincipal caps how many explicit transactions one authenticated
	// principal may hold open at once across all of its connections. Exceeding it
	// fails the BEGIN with Neo.ClientError.General.LimitExceeded rather than
	// queueing. Zero defaults to [DefaultMaxOpenTxPerPrincipal]; a NEGATIVE value
	// disables enforcement, which is deliberate and visible at the call site.
	MaxOpenTxPerPrincipal int

	// MaxConnections is the upper bound on concurrent accepted connections.
	// Zero or negative values default to 1024.
	MaxConnections int

	// MaxMessageBytes caps the cumulative payload size of a single Bolt
	// message reassembled from per-chunk fragments. Zero or negative
	// values default to [proto.DefaultMaxMessageBytes] (16 MiB).
	// Bolt's wire format limits each chunk to 65535 bytes but the
	// chunk count is unbounded; this cap closes the Slowloris-style
	// DoS vector in which a malicious client streams non-zero chunks
	// indefinitely until the server OOMs.
	MaxMessageBytes int

	// MaxInboundDecodeBytes is the engine-wide ceiling, in bytes, on the total
	// decoded-collection memory in flight across ALL connections while messages
	// are being decoded. MaxMessageBytes bounds a single message and the decoder
	// bounds a single message's decoded collections, but without this aggregate
	// bound that per-message cap times MaxConnections is unbounded and reachable
	// before authentication — a memory-exhaustion DoS (CWE-770). When the pool is
	// drawn down, further inbound decodes fail fast with a retryable transient
	// error (backpressure) rather than allocating.
	//
	// Interpretation mirrors cypher.EngineOptions.GlobalMaxResultBytes:
	//   - 0 (the zero value) → derive from the Go soft memory limit: one eighth
	//     of GOMEMLIMIT when the operator has set one (results already claim half,
	//     and inbound decode is transient), else unlimited. This gives default-on
	//     protection precisely when a memory budget is declared, and never rejects
	//     a legitimate workload on a host whose memory the module cannot know.
	//   - [MaxInboundDecodeBytesUnlimited] (-1) → unlimited (explicit opt-out).
	//   - a positive value → used verbatim.
	//
	// GOMEMLIMIT alone cannot mitigate the DoS: in-flight decoded values are live,
	// non-collectable memory during decode, so the ceiling must gate before the
	// allocation.
	//
	// Scope: the ceiling bounds the concurrent DECODE-phase memory across
	// connections — the simultaneous-allocation vector that is the actual OOM
	// risk. A message's reserved bytes are returned as soon as it finishes
	// decoding, so this does not bound a message's lifetime while its handler
	// runs; a single connection may still transiently hold one decoded message
	// (bounded by MaxMessageBytes) during handling. This mirrors the result
	// ceiling's transient-vs-lifetime asymmetry.
	MaxInboundDecodeBytes int64

	// MaxInFlightPerConnection caps the total number of RUN statements
	// that may be issued within a single explicit transaction before
	// COMMIT or ROLLBACK. Zero or negative values default to
	// [DefaultMaxInFlightPerConnection] (1024). The count includes both
	// open (not yet fully PULL'd) and already-drained cursors
	// accumulated in tx.results since BEGIN; auto-commit cursors are
	// not counted (the Bolt v5 state machine already prevents two
	// concurrent auto-commit streams). The cap surfaces as a typed
	// Bolt FAILURE with code "Neo.ClientError.General.LimitExceeded".
	MaxInFlightPerConnection int

	// ConnTimeout is the per-connection idle read deadline applied throughout
	// the post-handshake message loop. Each time the server is about to read
	// the next message, the deadline is reset to now+ConnTimeout, so it bounds
	// the silent gap between messages rather than the total session duration.
	// Zero or negative values default to [DefaultConnTimeout] (30 s); a
	// non-zero deadline is always applied so an idle connection cannot hold its
	// slot and goroutine forever. Set a larger value for long-lived idle
	// sessions. The unauthenticated handshake phase is bounded separately and
	// is not configurable here; see [DefaultHandshakeTimeout].
	ConnTimeout time.Duration

	// MaxStatementTimeout is the server-side upper bound on per-statement
	// execution time. When a client supplies a timeout via the RUN or BEGIN
	// extra metadata, it is silently clamped to MaxStatementTimeout. When
	// a client supplies no timeout and MaxStatementTimeout is positive, the
	// server applies MaxStatementTimeout unconditionally. Zero means no
	// server-side cap (client controls its own timeout).
	MaxStatementTimeout time.Duration

	// DefaultTxTimeout is the bounded timeout applied to an explicit transaction
	// (opened by BEGIN) when the client supplies no tx_timeout. It guarantees the
	// engine's former single-writer serialisation, which an explicit transaction held
	// from BEGIN until COMMIT/ROLLBACK, can never be held indefinitely by an
	// abandoned transaction (#1302). Zero or negative values default to
	// [DefaultTxTimeout] (30 s). A client-supplied tx_timeout takes precedence;
	// MaxStatementTimeout, when set, additionally clamps the effective value. Set
	// a larger value for long-lived batch transactions.
	DefaultTxTimeout time.Duration

	// DefaultStatementTimeout is the bounded timeout applied to an AUTOCOMMIT
	// statement (a bare RUN outside an explicit transaction) when the client
	// supplies no per-statement `timeout` of its own. It is the autocommit
	// counterpart of DefaultTxTimeout: without it, a default-configured server
	// (MaxStatementTimeout left at zero) has no wall-clock bound on an
	// autocommit statement, so an authenticated client can pin a CPU core
	// indefinitely with a super-linear-runtime / single-row-result query whose
	// result-row and byte caps never fire (#1828). Zero or negative values
	// default to [DefaultStatementTimeout] (30 s). A client-supplied `timeout`
	// takes precedence; MaxStatementTimeout, when set, additionally clamps the
	// effective value. Set a larger value for long-running analytical statements.
	DefaultStatementTimeout time.Duration
}

Options configures a Server. It is a plain configuration value read once by NewServer; it is safe for concurrent read use once constructed, but must not be mutated after being passed to NewServer. The referenced TLSConfig, Auth, Logger, and Closer carry their own concurrency contracts.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is the Bolt v5 TCP server. It accepts connections from a net.Listener, negotiates the protocol version, and runs the Bolt message loop on each connection.

Server is safe for concurrent use by multiple goroutines.

func NewServer

func NewServer(eng *cypher.Engine, opts Options) (*Server, error)

NewServer creates a Server backed by eng. Zero-value Options fields are filled with sensible defaults.

NewServer is secure-by-default: it never silently installs an accept-everyone authentication handler. If Options.Auth is nil it fails closed and returns ErrNoAuthHandler so that an unauthenticated server is never started by accident. To run without authentication on purpose (development or testing), set Options.Auth to a NoAuthHandler{} value explicitly: NewServer then admits every client and logs a loud warning that the operator has knowingly disabled authentication. The explicit NoAuthHandler value is itself the opt-in — self-documenting at the call site and impossible to set by accident. When Options.Auth is any other (real) handler it is used as-is.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(ctx context.Context, addr string) error

ListenAndServe creates a TCP listener on addr and calls Serve. It blocks until the server stops. The listener is closed when Serve returns.

func (*Server) Serve

func (s *Server) Serve(ctx context.Context, ln net.Listener) (err error)

Serve accepts connections from ln until ctx is cancelled or Shutdown is called. It blocks until all active connections have closed. The provided ln is closed by Serve when the accept loop exits.

Once every connection has drained, Serve also closes the owned Options.Closer (the store-level teardown owner for the durability stack, typically a *github.com/FlavioCFOliveira/GoGraph/store.DB), so stopping the server by cancelling ctx tears the WAL/checkpoint stack down in its crash-safe order exactly as Server.Shutdown does — no checkpoint goroutine or WAL handle outlives Serve. The close happens strictly after the drain, so it can never race an in-flight write, and it is once-guarded, so a subsequent (or concurrent) Shutdown does not close the closer again. A failed close is returned (joined with any accept error) rather than swallowed. After Serve returns, the durability stack is closed: the Server must not be reused to serve writes again.

Example

ExampleServer_Serve starts a Bolt server backed by an in-memory graph, connects a Bolt client, and runs a query over the session. The listener binds to 127.0.0.1:0 so the OS assigns a free port. Teardown closes the client first, then cancels Serve and waits for it to drain every connection goroutine — leaving no leaked goroutine behind.

The network round-trip is non-deterministic in timing, so the example asserts the deterministic query result rather than any wire-level output.

package main

import (
	"context"
	"fmt"
	"net"
	"time"

	"github.com/FlavioCFOliveira/GoGraph/bolt/server"
	"github.com/FlavioCFOliveira/GoGraph/cypher"
	"github.com/FlavioCFOliveira/GoGraph/graph/adjlist"
	"github.com/FlavioCFOliveira/GoGraph/graph/lpg"

	"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)

func main() {
	// Engine over an empty in-memory labelled property graph.
	g := lpg.New[string, float64](adjlist.Config{})
	eng := cypher.NewEngine(g)

	// The explicit NoAuthHandler{} value is the opt-in that lets this example
	// run without credentials; the server is secure-by-default and otherwise
	// refuses to start with a nil Auth handler.
	srv, err := server.NewServer(eng, server.Options{ConnTimeout: 5 * time.Second, Auth: server.NoAuthHandler{}})
	if err != nil {
		fmt.Println("new server:", err)
		return
	}

	// Ephemeral port; ln.Addr() reveals the chosen port for the client.
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		fmt.Println("listen:", err)
		return
	}
	addr := ln.Addr().String()

	ctx, cancel := context.WithCancel(context.Background())
	serveErr := make(chan error, 1)
	go func() { serveErr <- srv.Serve(ctx, ln) }()

	// Connect a Bolt client and run a trivial read query.
	driver, err := neo4j.NewDriverWithContext("bolt://"+addr, neo4j.NoAuth())
	if err != nil {
		fmt.Println("driver:", err)
		cancel()
		<-serveErr
		return
	}

	sess := driver.NewSession(ctx, neo4j.SessionConfig{})
	result, err := sess.Run(ctx, "RETURN 1 AS n", nil)
	if err != nil {
		fmt.Println("run:", err)
	} else if rec, err := result.Single(ctx); err != nil {
		fmt.Println("single:", err)
	} else {
		n, _ := rec.Get("n")
		fmt.Println("n =", n)
	}
	_ = sess.Close(ctx)

	// Clean shutdown: close the client so server-side connection goroutines
	// observe EOF, then cancel Serve and wait for it to return. Serve only
	// returns after every connection goroutine has finished.
	_ = driver.Close(ctx)
	cancel()
	<-serveErr
}
Output:
n = 1

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully stops accepting new connections and waits for active connections to finish. If connections do not finish within 30 seconds, it closes the listener forcefully and returns an error.

When the server was constructed with Options.Closer (the store-level teardown owner for the durability stack, typically a *github.com/FlavioCFOliveira/GoGraph/store.DB), Shutdown closes it AFTER every active connection has drained — so the WAL/checkpoint teardown runs in its crash-safe order only once no in-flight transaction can still be writing. Closing it before the drain could let a still-executing write race the WAL close. The closer is therefore NOT torn down on the timeout or ctx-cancellation paths: an undrained connection may still hold a transaction, so tearing the WAL down underneath it is exactly what must be avoided; in those cases the connections are abandoned. A still-running Server.Serve remains blocked on the same drain, and when the abandoned connections do eventually finish (idle timeout, transaction reap, client exit), Serve's own exit path performs the post-drain close — so the closer is torn down as soon as a full drain truly completes, and is left for process exit only if it never does. The close is once-guarded: whichever of Serve or Shutdown drains first runs it, and the other observes the same cached result, so the closer is never closed twice (including on a double Shutdown). A failed WAL close is surfaced rather than swallowed.

func (*Server) TerminateTransaction added in v0.11.0

func (s *Server) TerminateTransaction(id string) error

TerminateTransaction rolls back the open transaction with the given id, releasing the writer serialisation and visibility barrier it holds. It returns ErrNoSuchTransaction if no such transaction is open.

The rollback is performed by the connection that owns the transaction, on its own goroutine, because a Session is single-threaded by contract. This call therefore REQUESTS the rollback and returns once the request is delivered; the transaction's context is cancelled synchronously, so a statement already executing is interrupted immediately, and the rollback follows as soon as the owning loop observes the request. Use Server.Transactions to confirm it has gone.

The rollback is atomic: it unwinds every statement of the transaction, exactly as a client ROLLBACK would, so no partial state is left behind.

TerminateTransaction is safe to call from any goroutine.

func (*Server) Transactions added in v0.11.0

func (s *Server) Transactions() []TransactionInfo

Transactions returns a snapshot of every explicit transaction currently open on this server, oldest first.

It is the diagnostic half of the pair Server.TerminateTransaction completes.

The reason it matters CHANGED with rmp #2305/#2306, and the old reason is worth stating so nobody restores it: an open writing transaction used to hold the engine's writer serialisation and the visibility barrier for its whole lifetime, so "while one is open every reader waits" was literally true and an abandoned transaction was an outage. It no longer holds either. What an abandoned transaction pins now is the reclamation horizon — no version it could still read is freed while it lives — so the symptom is unbounded version memory rather than stalled clients, and finding it still requires knowing its principal, its age and its current statement.

Transactions is safe to call from any goroutine at any time, including while the server is serving. The returned slice and its elements are copies.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session holds all per-connection state for a single Bolt v5 client connection.

Session is NOT safe for concurrent use. Each accepted TCP connection owns exactly one Session, and the message loop is single-threaded per connection.

func (*Session) Close added in v0.2.0

func (s *Session) Close()

Close tears the session down on connection teardown: it drains any open cursor and rolls back any open explicit transaction so the engine writer serialisation is released immediately rather than lingering until the GC finalises the leaked Result/transaction (#1309). It is safe to call exactly once from the connection handler's deferred cleanup on every exit path (clean close, read or write error, panic). Idempotent: a second call, or a call on a session with no open transaction, is a no-op.

An explicit transaction still open at this point is an abnormal disconnect — the client dropped the connection (or hit an idle timeout, or the handler panicked) without sending COMMIT, ROLLBACK, or RESET. Close counts it as [metricTxAbandoned] before the rollback so an operator can distinguish a leaked transaction reclaimed here from one ended in an orderly way. This is the only site that emits tx.abandoned: a FAILED-transition reclaim (#1312) goes through [Session.abortTx] directly and is an in-session state change, not a disconnect, so it is not counted abandoned.

func (*Session) HandleMessage

func (s *Session) HandleMessage(ctx context.Context, msg any) ([]any, error)

HandleMessage dispatches msg to the correct per-state handler and returns the response messages to send to the client.

On an illegal state transition or internal error the session moves to FAILED and HandleMessage returns a single *proto.Failure response. The caller is responsible for encoding and sending all returned messages.

When a record sink is installed (see [Session.setRecordSink]), the RECORD messages of a PULL are written through the sink as the cursor is iterated and are NOT part of the returned slice, which then carries only the trailing SUCCESS or FAILURE. A sink write failure is surfaced as an error wrapping [errRecordWrite]: the connection framing is unrecoverable and the caller must tear the connection down without writing anything further.

type State

type State uint8

State represents the Bolt v5 per-connection protocol state machine state.

const (
	// StateConnected is the initial state: TCP connection established, no
	// protocol negotiation has occurred yet.
	StateConnected State = iota

	// StateNegotiation is reached after version negotiation; the server awaits
	// the client's HELLO message.
	StateNegotiation

	// StateAuthentication is the pre-LOGON state reached after a successful
	// credential-less HELLO on Bolt >= 5.1. Bolt 5.1 split authentication out of
	// HELLO into a dedicated LOGON message, so a 5.1+ client sends a HELLO
	// carrying only driver metadata and then a LOGON carrying the credentials.
	// In this state the connection is not yet authenticated; only LOGON, LOGOFF,
	// RESET, and GOODBYE are legal, and a successful LOGON transitions to
	// StateReady. On Bolt <= 5.0 (and the white-box tests, which run at the
	// zero-value version) HELLO authenticates inline and goes straight to
	// StateReady, so this state is never entered. (task #1470)
	StateAuthentication

	// StateReady is the idle state after a successful HELLO or after a result
	// set has been fully consumed, committed, or rolled back.
	StateReady

	// StateStreaming is active when a query has been run (auto-commit) and
	// records are available to pull.
	StateStreaming

	// StateTxReady is reached after BEGIN; the server awaits RUN, COMMIT, or
	// ROLLBACK within an explicit transaction.
	StateTxReady

	// StateTxStreaming is active when a query has been run inside an explicit
	// transaction and records are available to pull.
	StateTxStreaming

	// StateFailed is entered when a request fails; the server ignores further
	// requests until RESET is received.
	StateFailed

	// StateDefunct is the terminal state: the connection is closed and no
	// further messages are processed.
	StateDefunct
)

func HelloTransition added in v0.3.0

func HelloTransition(current State, ver proto.Version, success bool) (State, error)

HelloTransition computes the next state for a successful HELLO given the negotiated Bolt version. It is the version-aware variant of the NEGOTIATION→HELLO branch of Transition:

  • Bolt <= 5.0 (and the zero-value version used by direct white-box tests): HELLO authenticates inline and advances straight to StateReady.
  • Bolt >= 5.1: HELLO is credential-less by spec, so a successful HELLO advances to the pre-LOGON StateAuthentication, from which a successful LOGON reaches StateReady.

It is only valid in StateNegotiation; any other current state, or a failed HELLO, is delegated to Transition, which returns StateFailed (with ErrInvalidTransition for an illegal current state). (task #1470)

func StreamingTransition

func StreamingTransition(current State, hasMore bool) (State, error)

StreamingTransition is a variant of Transition for PULL in STREAMING or TX_STREAMING states when there are more records to deliver (has_more=true). In that case the connection remains in the same streaming state instead of returning to READY/TX_READY.

func Transition

func Transition(current State, msg any, success bool) (State, error)

Transition computes the next state given the current state, the incoming message, and whether the operation succeeded.

msg must be one of the pointer types from the proto package (e.g. *proto.Run, *proto.Pull, etc.). success indicates whether the server-side operation succeeded; on failure the next state is StateFailed (unless the transition itself is illegal).

Returns (StateFailed, ErrInvalidTransition) for illegal state/message combinations.

func (State) String

func (s State) String() string

String returns the name of the state for logging and diagnostics.

type TransactionInfo added in v0.11.0

type TransactionInfo struct {
	// StartedAt is when the BEGIN completed, from the server's clock.
	StartedAt time.Time

	// ID identifies the transaction for [Server.TerminateTransaction]. It is
	// unique for the lifetime of the server: a new transaction on the same
	// connection gets a new ID, so an ID captured from an earlier listing can
	// never terminate a later transaction that happens to reuse the connection.
	ID string

	// Principal is the authenticated identity that opened the transaction, empty
	// when the server runs without authentication and the client sent no
	// principal.
	Principal string

	// Remote is the client's network address, as the server sees it.
	Remote string

	// Mode is "w" for a writing transaction or "r" for a read-only one.
	//
	// NEITHER blocks anybody as of rmp #2305/#2306. A writing transaction used to
	// hold the engine's writer serialisation and the visibility barrier for its whole
	// lifetime, so an abandoned one was an outage and finding it was urgent; it now
	// holds only its own unpublished commit record and a reclamation-horizon slot.
	// What an abandoned writing transaction still costs is therefore version memory —
	// no version it can reach is reclaimable while it lives — not other clients'
	// progress.
	Mode string

	// State is the Bolt state machine state of the owning session, rendered for a
	// human — "TX_READY" between statements, "TX_STREAMING" while a result is
	// being drained.
	State string

	// Query is the text of the most recent statement RUN inside the transaction,
	// empty when BEGIN has not yet been followed by a RUN. It is the field that
	// answers "what is it doing?", which a counter and a log line cannot.
	Query string

	// Elapsed is how long the transaction has been open, measured at snapshot
	// time. It is provided rather than left to the caller because the server's
	// clock may be injected, and subtracting StartedAt from time.Now() would then
	// be wrong.
	Elapsed time.Duration
}

TransactionInfo describes one explicit Bolt transaction that is currently open. It is a point-in-time snapshot taken under the registry's lock: the values are copies, so holding one blocks nothing and observes no later change.

TransactionInfo is safe for concurrent use because it is immutable once returned by Server.Transactions.

type Tx

type Tx struct {
	// contains filtered or unexported fields
}

Tx wraps an engine-level explicit transaction (cypher.ExplicitTx) for a single Bolt transaction opened by a BEGIN message. Every RUN issued between BEGIN and COMMIT/ROLLBACK executes against the SAME underlying engine transaction, so the statements are atomic together: COMMIT makes them durable and visible as one unit, ROLLBACK unwinds all of them (#1280). This replaces the previous behaviour in which each RUN opened and committed its own autocommit transaction and ROLLBACK undid nothing.

Tx is NOT safe for concurrent use; it is owned by a single Session whose message loop is single-threaded per connection.

func (*Tx) Commit

func (tx *Tx) Commit() error

Commit makes every statement issued since BEGIN durable and visible as one atomic unit, then releases the transaction's resources. The engine fsyncs the WAL exactly once for the whole transaction (WAL-backed) and commits the secondary-index buffer; on a store-less engine the writes are already visible and the index buffer is finalised. The writer serialisation is released.

Open result cursors are closed first (releasing their iterator state); the commit decision itself is made by the engine transaction, not by the cursors.

func (*Tx) Rollback

func (tx *Tx) Rollback() error

Rollback unwinds every statement issued since BEGIN — restoring the in-memory graph to its pre-transaction state via the engine's accumulated undo log, and (WAL-backed) discarding the WAL transaction so a fresh recovery observes none of the writes — then releases the transaction's resources. It is best-effort and always releases the writer serialisation, even if an inverse operation fails.

func (*Tx) Run

func (tx *Tx) Run(query string, params map[string]any) (*cypher.Result, error)

Run executes query inside the transaction WITHOUT committing, buffers the result cursor, and returns it to the caller for streaming. The statement's writes accumulate in the engine transaction and become durable/visible only on Commit.

Runtime pipeline errors (where the query compiled and executed under the visibility barrier but the execution pipeline failed — e.g. a constraint violation or a type error mid-pipeline) are wrapped in a zero-row cypher.Result whose Err() carries the error. This preserves the Bolt v5 state-machine contract: the session stays in TX_STREAMING so the driver can drain the cursor via PULL (where the FAILURE surfaces), rather than receiving a FAILURE directly from RUN. Build-phase errors (context cancellation, parse/sema/plan failures, DDL rejection) are propagated as a non-nil error return so the server enters FAILED at RUN time.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL