You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Design exploration, on the back burner. Nothing here is approved or
scheduled. This issue exists so the thinking is not lost — an earlier draft was
withdrawn pending a separate prerequisite, and the useful conclusions are
recorded here instead.
Two of the conclusions are negative, and they are the interesting ones: the
headline framing of the original proposal turns out to be wrong in a specific
and useful way, and the most ambitious version of the feature should not be
built at all. A narrower subset is worth doing, and exactly one small piece of
it has a cost that rises at the 1.0.0 freeze whether or not the rest ever
happens.
The idea
hyperdb-mcp runs a resident hyperd that multiple MCP sessions share.
Discovery goes through a record in a state directory, liveness through a
localhost health/control port, and the daemon notices when hyperd dies and
restarts it under a bounded restart policy.
Every other consumer of hyperdb-api — tests, examples, CLI tools, the
benchmark suite, downstream applications — spawns a private hyperd through HyperProcess::new.
Real engineering went into that daemon and it is under-leveraged. The proposal
was a mode meaning "use the shared daemon rather than spawning your own hyperd", so that short-lived processes stop paying hyperd startup cost and
stop multiplying hyperd instances.
Who benefits
Test suites. Hundreds of sequential server starts, one application, one
build. A recorded make test run was 121 s for 1515 tests, and the suite has
a large number of helper-backed spawn sites, so this is where startup cost is
paid most often.
Repeatedly-invoked CLIs. A tool called in a loop or a script pays a full
spawn per invocation against a short workload.
Warm serverless pools running the same function. Many short processes,
one trust domain — but only if the daemon survives between invocations, which
is platform-dependent.
Note what these have in common: the processes sharing the engine are the same
application, the same build, and the same trust domain. That observation drives
the rest of the design.
The key architectural finding
This is the most valuable conclusion in the exploration, and it reshapes
everything after it.
"Expose a new mode meaning use the shared daemon rather than spawning your own hyperd" implies hyperdb-api cannot currently talk to a hyperd it does not
own. It can, and always could. Verified against the tree:
Connection::connect(endpoint, database_path, create_mode) takes a bare
endpoint string (hyperdb-api/src/connection.rs:247), as do Connection::without_database.
ConnectionBuilder::new(endpoint) is endpoint-based
(hyperdb-api/src/connection_builder.rs:78).
AsyncConnection::connect(endpoint, database, mode) likewise
(hyperdb-api/src/async_connection.rs:80). There is no AsyncConnection
constructor taking a HyperProcess at all, so async callers already pass an
endpoint string.
PoolConfig { endpoint, .. } and SyncPoolConfig { endpoint, .. } are
endpoint-based (hyperdb-api/src/pool.rs:232, :690); pool.rs does not
mention HyperProcess anywhere.
grpc::GrpcConnection::connect(endpoint, database_path) and its async twin
likewise (hyperdb-api/src/grpc_connection.rs:138, :350).
None of them hold a HyperProcess, and none stop hyperd on drop. HyperProcess is the only type in hyperdb-api whose Drop stops the
process (hyperdb-api/src/process.rs:1125); no other impl Drop in the crate
touches engine lifetime.
The MCP's own daemon mode is built out of exactly that public path. Engine::try_daemon_mode calls Connection::connect(endpoint, …) and stores hyper: None, so its Drop structurally cannot stop the shared engine
(hyperdb-mcp/src/engine.rs:606, :651).
So what is genuinely absent from hyperdb-api is much narrower than the
proposal assumes:
Discovery — turning "the shared daemon, wherever it is" into an endpoint.
Supervision — starting the daemon if absent, restarting hyperd when it
dies, shutting down when idle.
Item 2 requires a daemon process, which requires a binary, argument parsing,
and a logging subscriber. hyperdb-api must not grow those — and, having no
feature flags by design (confirmed: hyperdb-api/Cargo.toml has no [features] section at all), it could not gate them if it did.
The daemon is therefore a supervisor that consumesHyperProcess. It is a
higher layer than hyperdb-api, not a lower one. The assumed direction is
inverted:
Assumed: the daemon must move DOWN
hyperdb-mcp -> hyperdb-api -> daemon/* relocated into api or core [wrong]
Actual: the daemon belongs ABOVE
hyperdb-mcp -> hyperdb-daemon -> hyperdb-api
other consumers -> hyperdb-daemon
(CLIs, tests, scripts)
Consequence: in the minimum viable slice, hyperdb-api gains no public API,
no dependency, and nothing entering the 1.0 freeze. That is both the cheapest
answer and the correct one.
Where the code should live
A new hyperdb-daemon crate that depends on hyperdb-api, shipping a
client library plus a hyperdb-daemon binary behind a default = ["cli"]
feature — exactly mirroring hyperdb-bootstrap, which already sets that
precedent (default = ["cli"], cli = ["dep:clap", "dep:anyhow", "dep:tracing-subscriber"], consumable as a pure library with default-features = false). hyperdb-mcp then depends on hyperdb-daemon and
drops its own daemon/*. Dependency direction is hyperdb-daemon → hyperdb-api,
the same direction hyperdb-mcp → hyperdb-api already goes, so no layering rule
bends.
Alternatives considered and rejected:
Into hyperdb-api — needs a binary, so clap and a subscriber, which
with no feature flags every user pays for; and it puts a resident-service
surface inside the 1.0 semver freeze.
Into hyperdb-api-core — worse. That crate is positioned as
forever-internal and sits below the API; supervision is strictly above the
wire protocol.
A crate that hyperdb-api depends on — makes discovery a mandatory
transitive dependency for every hyperdb-api user, most of whom will never
use it, with no flag available to opt out.
What stays in hyperdb-mcp is all of the policy: attachment replay, the
persistent/ephemeral database model, _table_catalog, the KV store, watched
directories, the doctor, and the product's own version-takeover UX. The
genuinely generic residue is the discovery record, the port scan, the
control-protocol shape, HyperProcess ownership with a restart limiter, and the
detached-spawn skeleton.
Isolation is the central open problem
Treat this as a prerequisite, not a detail to sort out during
implementation. It is the difference between a feature that is safe to offer
and one that is not.
The resource argument
memory_limit is a Hyper instance-global parameter, applied to the process
at startup through Parameters and documented as "Hyper's global memory limit",
default 80 % of host RAM (hyperdb-api/tests/stress_test/README.md:173, hyperdb-api/tests/stress_test/simulation.rs:271). Searches for soft_memory_limit, hard_memory_limit, admission control, or any per-session
limit find nothing.
There is no per-session resource isolation to configure. One tenant's
oversized query applies memory pressure to every other tenant on the same
instance, and there is no knob to prevent it, because the engine does not expose
one. This is not a gap in the daemon — it is a property of the engine, and it is
not fixable in this repository.
Blast radius
When a shared hyperd dies, every tenant loses in-flight transactions and
every session's attach state. The daemon restarts hyperd, but attachment
replay lives in hyperdb-mcp's AttachRegistry — it is product policy, not a
library guarantee. A library client would simply find its attachments silently
gone against a freshly restarted engine. Rebuilding session state after a
peer-induced restart would be a burden pushed onto every caller.
Related: the current version-takeover rule (a client whose version is strictly
greater terminates the incumbent daemon and respawns) is actively dangerous when
generalised. Application A on v1.2 would terminate the daemon that application B
on v1.1 is mid-transaction against. A library must never take over. If the
resident daemon speaks a compatible control protocol, use it as-is even if
older; if it does not, start a separate daemon rather than displacing the
incumbent. Deliberate takeover stays available as an explicit operator action in
the CLI, where a human is choosing it.
The recommended answer: cohort scoping
Do not multi-tenant. Key the daemon to a cohort — a caller-supplied
identifier defaulting to something derived from the calling application, rather
than to a global constant. Different cohorts get different daemons, different hyperd processes, and therefore real isolation, while still getting the
warm-start sharing that motivated the whole idea.
What this buys: multi-tenancy stops being a blocker and becomes a documented
boundary; the instance-global memory_limit problem stops being
cross-application; blast radius is scoped to one application's own processes;
and version skew mostly disappears, because a cohort is usually one build.
What it costs: a machine running five cohorts runs five hyperd processes,
which is worse than one and much better than one-per-process. That is the right
place on the curve, and it is honest about not being free.
Open experiment, currently unresolved
No test proves whether one session on a shared hyperd sees another
session's attached databases. The API models attach per connection
(attach_database/detach_database are Connection methods emitting ATTACH/DETACH DATABASE), and the MCP's registry is per-process, so the
expectation is that it does not — but the expectation is untested.
The nearest existing evidence proves a different claim: every case in hyperdb-mcp/tests/attach_tests.rs constructs engines via Engine::new_no_daemon, i.e. two private engines, so what those tests show
is that attach state does not survive a new process.
The experiment is cheap and should be run before anything ships: two connections
to onehyperd; A attaches a file under an alias; B queries that alias and
also enumerates pg_catalog.pg_database. Land it as a permanent
characterization test whichever way it resolves, so the documented model has
evidence behind it. If B can see A's attachment, cohort scoping moves from
strongly recommended to mandatory.
Trust boundary — deferred
Trust boundary — deferred. Establishing an authentication and isolation
model for a shared engine is a prerequisite for this work and is being handled
separately outside this issue.
Proposed public API sketch
Additive, and living entirely in hyperdb-daemon. Note that the connect calls
below are today's unmodified hyperdb-api calls.
Callers have three genuinely different intentions and must be able to say which:
/// How to obtain a `hyperd` to talk to.pubenumAcquisition{/// Spawn a private `hyperd`. Today's behaviour; remains the default.Private,/// Use the shared cohort daemon. Fail if it cannot be reached or started.Shared,/// Prefer the shared cohort daemon; fall back to a private `hyperd`./// Never slower than `Private` by more than `fallback_deadline`.SharedOrPrivate,}
/// A `hyperd` obtained by either route. Knows whether it owns the process.pubenumEngine{Private(hyperdb_api::HyperProcess),Shared(SharedEngine),}implEngine{/// libpq endpoint, usable with every `hyperdb-api` connect API.pubfnendpoint(&self) -> &str;pubfnconnection_endpoint(&self) -> &ConnectionEndpoint;pubfnacquired(&self) -> Acquired;// Private | Shared/// Why `Shared` was declined, when `SharedOrPrivate` fell back.pubfnfallback_reason(&self) -> Option<&FallbackReason>;}/// Drop releases the lease and stops heartbeating./// It does NOT stop `hyperd` — that asymmetry with `HyperProcess::drop` is/// the whole point and is the invariant most worth testing.pubstructSharedEngine{/* … */}#[non_exhaustive]#[derive(Clone,Debug)]pubstructOptions{pubcohort:Cohort,pubstate_dir:Option<PathBuf>,pubidle_timeout:Option<Duration>,// Some(_) by default, unlike todaypubfallback_deadline:Duration,pubhyperd_path:Option<PathBuf>,pubparameters:Option<Parameters>,// applied only when we start it}pubfnacquire(how:Acquisition,options:&Options) -> Result<Engine>;pubasyncfnacquire_async(how:Acquisition,options:&Options) -> Result<Engine>;
The central invariant
A shared engine's Drop must release its lease without stopping hyperd.
That is the deliberate asymmetry with HyperProcess::drop, and it is the single
most important property in the crate. The test that pins it should assert both
halves so it cannot pass by accident: acquire a shared engine, drop it, prove
the daemon and its hyperd are still alive and serving; then acquire a private
engine, drop it, prove hyperd exited.
This asymmetry is also why a non-stopping variant of HyperProcess is a
non-goal. HyperProcess::drop is load-bearing for the test suite — TestConnection and TestServer both hold a HyperProcess field purely so
that scope exit reaps the server — so a non-stopping variant would silently
orphan a server per test. Shared handles must be a distinct type.
Other API notes
Options is #[non_exhaustive] deliberately. ChartOptions in hyperdb-mcp
was found to be source-breaking to extend precisely because it was not, and
this crate will grow knobs.
No silent fallback.Shared returns a typed error naming why (no
daemon reachable, spawn timed out, protocol unsupported). SharedOrPrivate
succeeds but reports which mode it got and why the preferred one was
declined, and logs at warn. The MCP currently logs its fallback at debug
and returns Ok(None), which is exactly why a ten-second stall in the
discovery path went unnoticed in normal operation (now fixed, fix(mcp): discover() parses the whole DaemonRecord strictly, then discards what the strictness bought #270).
SharedOrPrivate carries a deadline, sized from measurement, so the
preferring mode is provably not a pessimisation. This is a correctness
property, not a tuning knob.
Default idle_timeout should be Some(_). Today's daemon defaults to None and runs forever unless configured. That is right for a product that
wants to stay warm for its user and wrong for a library: a cargo test run
must not leave a resident service behind.
Prefer a lease over pure heartbeat idling. Heartbeat-only has a real
failure mode — a client that is alive but idle past the timeout has the engine
shut down underneath it. Clients would register on acquire and release on
drop, with the idle timer running only while the lease count is zero;
heartbeats remain the mechanism that expires leases held by processes that
died without releasing. This needs new control commands, hence a protocol
version bump.
Version the control protocol separately from the crate. Crate semver is
the wrong compatibility axis for a wire protocol, and report_hyperd_error_to_daemon changed its public signature in a patch release with no BREAKING changelog entry #276 is the evidence.
Add an explicit protocol integer to the discovery record and the ping
response; a client supports protocol N and N-1, and a daemon advertising an
unsupported protocol is treated as absent rather than as an error, so the
client starts its own daemon instead of failing.
Before and after
The Connection::new line changes to Connection::connect; nothing else moves.
// todaylet hyper = HyperProcess::new(None,None)?;let conn = Connection::new(&hyper,"db.hyper",CreateMode::CreateIfNotExists)?;// shared, or private if the daemon is unavailablelet engine = hyperdb_daemon::acquire(Acquisition::SharedOrPrivate,&Options::for_cohort("my-cli"),)?;let conn = Connection::connect(engine.endpoint(),"db.hyper",CreateMode::CreateIfNotExists)?;if engine.acquired() == Acquired::Private{
tracing::warn!(reason = ?engine.fallback_reason(),"shared engine unavailable");}
// and the pool needs no change at alllet pool = create_pool(PoolConfig::new(engine.endpoint(),"db.hyper"))?;
That pool line is the clearest illustration of the architectural finding:
connection pooling over a shared daemon works today, with no new API,
because PoolConfig.endpoint is already a string and the pool has never owned a
process.
The benefit is unmeasured, and measuring it should gate the work
The entire premise is that sharing saves startup cost. This repository does
not contain a measurement of hyperd spawn-to-usable wall clock. Both
benchmark documents were checked directly:
docs/BENCHMARK_GUIDE.md measures throughput. Its only spawn hits are spawn_blocking task spawns and "task-spawn overhead".
docs/hyperd-release-benchmarks.md mentions "cold-start variance", but in
context that refers to insert-throughput variance, not process spawn.
The honest state is one figure from an adjacent code path and one CI upper
bound:
Figure
What it actually measured
Source
~156 ms
First embedded Hyper start in a proc-macro host
hyperdb-api-derive/README.md:216
"10+ seconds under load"
CI upper bound, hyperd startup alone, "especially macOS"
hyperdb-mcp/tests/daemon_tests.rs:1774
No hyperd memory figure exists anywhere in the repository — searches find
only benchmark-host RAM totals and a qualitative "reduced memory overhead"
claim in the MCP README.
This is not a reason to abandon the idea. It is a reason not to design against a
number nobody has, because the answer changes the design:
If cold spawn is ~150 ms, the warm win per process is ~150 ms, and this is
worth doing only for workloads that pay it hundreds of times.
If cold spawn is seconds on the platforms people actually use, the win is
large and the feature is clearly justified.
If cold spawn is cheaper than the daemon's own cold-acquisition path, the
feature is net-negative for the first process and only ever wins on the
second — still fine, but it changes what the default should be.
How to measure it
Time HyperProcess::new(None, None) plus one trivial query to first row. 20 iterations, report median and p95, release build.
Both transports (TransportMode::Tcp and TransportMode::Ipc).
macOS and Linux, recording the exact host, the hyperd version from hyperdb-bootstrap/hyperd-version.toml, and whether the page cache was warm.
Then time warm acquisition — discovery plus connect against an
already-running daemon — over the same iteration count. The delta between
those two medians is the per-process win, and warm acquisition is the number
the design actually needs, because discovery is not free either.
Separately time the degraded discovery paths, because they size fallback_deadline: warm hit, dead-port timeout, and a full port scan.
Sample hyperd RSS at idle and after a representative query, and total RSS
for N = 1, 4, 16 concurrent processes. Note the interaction: memory_limit
defaults to 80 % of host RAM and is instance-global, so N private processes
each believe they may use 80 % of the machine — which is an argument for
sharing on memory grounds that nobody has quantified.
Do not assert on a duration in a test. Print timings under --nocapture
and assert nothing, following the existing spike pattern. A wall-clock
assertion is a flaky test on shared CI.
Record the methodology and figures in docs/BENCHMARK_GUIDE.md. Do not
add a row to docs/hyperd-release-benchmarks.md — that file takes a row on a hyperd pin bump or a material API change, and conflating a startup
measurement with it would make a future engine delta unattributable.
Where it pays off, and where it does not
Workload shape
Verdict
Why
Test suite, hundreds of sequential server starts
Clear win
Pays spawn cost most often; single application and build
CLI invoked repeatedly in a loop or script
Clear win
Per-invocation spawn dominates a short workload
Serverless warm pool, same function
Likely win
Many short processes, one trust domain — if the daemon survives between invocations
Long-running server, one process
Neutral to negative
Spawn paid once; adds a discovery dependency, a second failure domain, and a peer that can take the engine down
Concurrent bulk ingest from several processes
Actively worse
See below
Unrelated applications sharing one engine
Do not
Instance-global memory_limit, shared blast radius
The "actively worse" row is measured, not guessed. From docs/BENCHMARK_GUIDE.md (Apple M3 Max, 96 GB, hyperd0.0.26479,
2026-09-05, medians of 5):
Workload
1 connection
4 connections
Direction
AsyncArrowInserter, 100M rows
68.90 M/s
48.47 M/s
~30 % worse
query.full_scan, async
24.91 M/s
73.45 M/s
~2× better
query.filtered, async
26.90 M/s
48.31 M/s
better
The guide states plainly that "Parallelism no longer helps Arrow inserts" —
single-connection AsyncArrowInserter outruns the 4-connection variant, "so
spending connections on an Arrow insert buys nothing on this host"
(docs/BENCHMARK_GUIDE.md:201). Read for this design: one hyperd serves
concurrent readers well and concurrent bulk writers poorly. A shared daemon
whose tenants are all ingesting will contend on exactly the workload where extra
connections already measure negative.
Two caveats on that reading. The guide warns the ×4 rows are
"order-of-magnitude only", with a ±20–61 % spread, because four workers contend
on a 14-core laptop. And the Windows figures invert the insert result
(single-connection AsyncArrowInserter 5.39 M/s versus 20.28 M/s at ×4 over
TCP), so the conclusion is host- and engine-version-specific, not universal.
The win is narrower than the original proposal implies. It is concentrated
in many-short-processes-same-application, which is real and worth serving, and
it is absent or negative for the single long-lived process that most library
users actually are.
Prerequisites and related work
fix(mcp): recover when a live hyperd becomes unresponsive #242 — a live-but-unresponsive hyperd is never recovered. This is a
prerequisite, not a follow-on. Reproduced by suspending the managed child
process: a SELECT 1 blocked for over 30 s, the daemon kept seeing the
process as alive, and never restarted it. Acceptable for a tool a developer can restart; not
acceptable for a library that silently enrolled the caller in a resident
service.
report_hyperd_error_to_daemon changed its public signature in a patch release with no BREAKING changelog entry #276 — should hyperdb-mcp's daemon::* be public? Directly related, and
extracting the daemon into its own crate would resolve it. The
recommendation recorded on that issue was to narrow the surface, on the
evidence that crates.io reverse dependencies for hyperdb-mcp are zero,
no workspace crate depends on it, and the crate's own lib.rs already
declares it "not a documented API surface". Extraction gives that surface a
home in a crate whose stated job is exactly that, where it can carry its own
compatibility promise.
The daemon has little elapsed field exposure, and macOS CI still skips its
crash-and-restart tests. Promoting it to a library capability while its
crash-recovery coverage runs on a subset of platforms is not defensible. This
is an argument for elapsed time, not more code.
Recommendation on timing
The user has put the broader feature on the back burner. The one piece with
a real deadline is the extraction.
Extract daemon/* into hyperdb-daemon before 1.0.0. It removes a
public module from hyperdb-mcp, which is free before 1.0.0 and a major
bump after. It is worth doing on report_hyperd_error_to_daemon changed its public signature in a patch release with no BREAKING changelog entry #276's merits alone, independent of
whether any of the rest ever ships, and it is the cheapest piece — a move
plus a re-export decision. Scope discipline matters: it is a move, not a
redesign. The evidence that it worked is the existing daemon test suite
passing unchanged under the new crate, reported as a characterization rather
than dressed up as red-before-green.
Measure startup cost and hyperd RSS. Cheap, and it gates whether any of
the rest is justified.
Then, if the measurement holds, ship cohort-scoped acquire() as a 0.1.0 of the new crate, after 1.0.0, with Private as the default and no
silent fallback.
Do not build cross-application sharing. The instance-global memory_limit and the shared blast radius make it indefensible, and neither
is fixable in this repository.
The highest-value follow-on, once numbers exist, is teaching the test helpers to
accept a shared engine — but the risk there is the whole phase. Tests
currently get a fresh engine each time, so sharing one means leaked state
between tests. Enumerate what leaks (temp tables, attach aliases, session
settings, memory pressure), prove per-test cleanup, convert one file, measure
the delta, and stop for review before converting the suite.
If a shared-engine handle should ever be usable where a HyperProcess is
usable, that parameter must become a trait bound. Generalising a concrete
parameter to impl Trait is source-compatible for ordinary call sites but not
for turbofish or function-pointer uses, so it is cheapest to do — or decide
against — before the freeze.
The recommendation is not to do it.Connection::connect(endpoint, …)
already covers the shared case, and adding a trait purely for symmetry is
speculative generality; adding it later is a minor addition, since a new trait
plus a new inherent method breaks nothing. But it is the only identified hyperdb-api change whose cost rises after 1.0.0, so it wants an explicit
human confirmation rather than a default.
Non-goals
No remote or network daemon. Loopback and local IPC only. This is not a hyperd broker.
No cross-application sharing, per the isolation section.
No attachment replay, catalog, KV store, doctor, or watched directories in
the new crate. That is MCP policy and stays there.
No replacement for HyperProcess.Private remains the default and HyperProcess::drop keeps stopping the process, because the test suite
depends on it.
No non-stopping variant of HyperProcess. Shared handles are a distinct
type.
No feature flag on hyperdb-api. Firm repository constraint; the design
satisfies it by adding nothing to hyperdb-api at all.
No changes to release automation, crate versions, or the root changelog.
Open questions needing a human decision
Does the measured benefit justify building this at all? Blocked on the
measurement above. This is the real go/no-go.
What is the default cohort? Derived from the executable path, from the
crate name, or a required explicit argument with no default? A path-derived
default silently splits cohorts when a binary is rebuilt to a different
location; a required argument is more honest but less ergonomic.
Leases, or heartbeat-only idle? Leases avoid shutting the engine out from
under an idle-but-live client, at the cost of new control commands and a
protocol bump. Is that complexity warranted in a first release, or is a
generous idle timeout plus heartbeats enough?
Should Connection::new be generalised to a trait before 1.0.0?
Recommendation: no. Deadline: 1.0.0.
Does hyperdb-mcp keep its version-takeover behaviour after extraction, or
does it become a CLI-only operator action? The MCP's binary-upgrade UX
currently depends on it.
Do the C++, Python, or Java Hyper APIs — or upstream Hyper itself — already
have a shared-instance concept? Nothing in this repository describes one,
but that is absence of evidence, and it rests on a grep of this tree. Needs
external verification before "novel" is claimed anywhere public, and
terminology should be aligned rather than invented if a concept already
exists.
Recorded from an offline design exploration and its phased plan, both verified
against the tree on 2026-09-06. The documents themselves are deliberately not
committed. Every code reference above was re-checked against the repository
before being repeated here.
Status
Design exploration, on the back burner. Nothing here is approved or
scheduled. This issue exists so the thinking is not lost — an earlier draft was
withdrawn pending a separate prerequisite, and the useful conclusions are
recorded here instead.
Two of the conclusions are negative, and they are the interesting ones: the
headline framing of the original proposal turns out to be wrong in a specific
and useful way, and the most ambitious version of the feature should not be
built at all. A narrower subset is worth doing, and exactly one small piece of
it has a cost that rises at the
1.0.0freeze whether or not the rest everhappens.
The idea
hyperdb-mcpruns a residenthyperdthat multiple MCP sessions share.Discovery goes through a record in a state directory, liveness through a
localhost health/control port, and the daemon notices when
hyperddies andrestarts it under a bounded restart policy.
Every other consumer of
hyperdb-api— tests, examples, CLI tools, thebenchmark suite, downstream applications — spawns a private
hyperdthroughHyperProcess::new.Real engineering went into that daemon and it is under-leveraged. The proposal
was a mode meaning "use the shared daemon rather than spawning your own
hyperd", so that short-lived processes stop payinghyperdstartup cost andstop multiplying
hyperdinstances.Who benefits
build. A recorded
make testrun was 121 s for 1515 tests, and the suite hasa large number of helper-backed spawn sites, so this is where startup cost is
paid most often.
spawn per invocation against a short workload.
one trust domain — but only if the daemon survives between invocations, which
is platform-dependent.
Note what these have in common: the processes sharing the engine are the same
application, the same build, and the same trust domain. That observation drives
the rest of the design.
The key architectural finding
This is the most valuable conclusion in the exploration, and it reshapes
everything after it.
"Expose a new mode meaning use the shared daemon rather than spawning your own
hyperd" implieshyperdb-apicannot currently talk to ahyperdit does notown. It can, and always could. Verified against the tree:
Connection::connect(endpoint, database_path, create_mode)takes a bareendpoint string (
hyperdb-api/src/connection.rs:247), as doConnection::without_database.ConnectionBuilder::new(endpoint)is endpoint-based(
hyperdb-api/src/connection_builder.rs:78).AsyncConnection::connect(endpoint, database, mode)likewise(
hyperdb-api/src/async_connection.rs:80). There is noAsyncConnectionconstructor taking a
HyperProcessat all, so async callers already pass anendpoint string.
PoolConfig { endpoint, .. }andSyncPoolConfig { endpoint, .. }areendpoint-based (
hyperdb-api/src/pool.rs:232,:690);pool.rsdoes notmention
HyperProcessanywhere.grpc::GrpcConnection::connect(endpoint, database_path)and its async twinlikewise (
hyperdb-api/src/grpc_connection.rs:138,:350).HyperProcess, and none stophyperdon drop.HyperProcessis the only type inhyperdb-apiwhoseDropstops theprocess (
hyperdb-api/src/process.rs:1125); no otherimpl Dropin the cratetouches engine lifetime.
Engine::try_daemon_modecallsConnection::connect(endpoint, …)and storeshyper: None, so itsDropstructurally cannot stop the shared engine(
hyperdb-mcp/src/engine.rs:606,:651).So what is genuinely absent from
hyperdb-apiis much narrower than theproposal assumes:
hyperdwhen itdies, shutting down when idle.
Item 2 requires a daemon process, which requires a binary, argument parsing,
and a logging subscriber.
hyperdb-apimust not grow those — and, having nofeature flags by design (confirmed:
hyperdb-api/Cargo.tomlhas no[features]section at all), it could not gate them if it did.The daemon is therefore a supervisor that consumes
HyperProcess. It is ahigher layer than
hyperdb-api, not a lower one. The assumed direction isinverted:
Consequence: in the minimum viable slice,
hyperdb-apigains no public API,no dependency, and nothing entering the 1.0 freeze. That is both the cheapest
answer and the correct one.
Where the code should live
A new
hyperdb-daemoncrate that depends onhyperdb-api, shipping aclient library plus a
hyperdb-daemonbinary behind adefault = ["cli"]feature — exactly mirroring
hyperdb-bootstrap, which already sets thatprecedent (
default = ["cli"],cli = ["dep:clap", "dep:anyhow", "dep:tracing-subscriber"], consumable as a pure library withdefault-features = false).hyperdb-mcpthen depends onhyperdb-daemonanddrops its own
daemon/*. Dependency direction ishyperdb-daemon → hyperdb-api,the same direction
hyperdb-mcp → hyperdb-apialready goes, so no layering rulebends.
Alternatives considered and rejected:
hyperdb-api— needs a binary, soclapand a subscriber, whichwith no feature flags every user pays for; and it puts a resident-service
surface inside the 1.0 semver freeze.
hyperdb-api-core— worse. That crate is positioned asforever-internal and sits below the API; supervision is strictly above the
wire protocol.
hyperdb-apidepends on — makes discovery a mandatorytransitive dependency for every
hyperdb-apiuser, most of whom will neveruse it, with no flag available to opt out.
What stays in
hyperdb-mcpis all of the policy: attachment replay, thepersistent/ephemeral database model,
_table_catalog, the KV store, watcheddirectories, the doctor, and the product's own version-takeover UX. The
genuinely generic residue is the discovery record, the port scan, the
control-protocol shape,
HyperProcessownership with a restart limiter, and thedetached-spawn skeleton.
Isolation is the central open problem
Treat this as a prerequisite, not a detail to sort out during
implementation. It is the difference between a feature that is safe to offer
and one that is not.
The resource argument
memory_limitis a Hyper instance-global parameter, applied to the processat startup through
Parametersand documented as "Hyper's global memory limit",default 80 % of host RAM (
hyperdb-api/tests/stress_test/README.md:173,hyperdb-api/tests/stress_test/simulation.rs:271). Searches forsoft_memory_limit,hard_memory_limit, admission control, or any per-sessionlimit find nothing.
There is no per-session resource isolation to configure. One tenant's
oversized query applies memory pressure to every other tenant on the same
instance, and there is no knob to prevent it, because the engine does not expose
one. This is not a gap in the daemon — it is a property of the engine, and it is
not fixable in this repository.
Blast radius
When a shared
hyperddies, every tenant loses in-flight transactions andevery session's attach state. The daemon restarts
hyperd, but attachmentreplay lives in
hyperdb-mcp'sAttachRegistry— it is product policy, not alibrary guarantee. A library client would simply find its attachments silently
gone against a freshly restarted engine. Rebuilding session state after a
peer-induced restart would be a burden pushed onto every caller.
Related: the current version-takeover rule (a client whose version is strictly
greater terminates the incumbent daemon and respawns) is actively dangerous when
generalised. Application A on v1.2 would terminate the daemon that application B
on v1.1 is mid-transaction against. A library must never take over. If the
resident daemon speaks a compatible control protocol, use it as-is even if
older; if it does not, start a separate daemon rather than displacing the
incumbent. Deliberate takeover stays available as an explicit operator action in
the CLI, where a human is choosing it.
The recommended answer: cohort scoping
Do not multi-tenant. Key the daemon to a cohort — a caller-supplied
identifier defaulting to something derived from the calling application, rather
than to a global constant. Different cohorts get different daemons, different
hyperdprocesses, and therefore real isolation, while still getting thewarm-start sharing that motivated the whole idea.
What this buys: multi-tenancy stops being a blocker and becomes a documented
boundary; the instance-global
memory_limitproblem stops beingcross-application; blast radius is scoped to one application's own processes;
and version skew mostly disappears, because a cohort is usually one build.
What it costs: a machine running five cohorts runs five
hyperdprocesses,which is worse than one and much better than one-per-process. That is the right
place on the curve, and it is honest about not being free.
Open experiment, currently unresolved
No test proves whether one session on a shared
hyperdsees anothersession's attached databases. The API models attach per connection
(
attach_database/detach_databaseareConnectionmethods emittingATTACH/DETACH DATABASE), and the MCP's registry is per-process, so theexpectation is that it does not — but the expectation is untested.
The nearest existing evidence proves a different claim: every case in
hyperdb-mcp/tests/attach_tests.rsconstructs engines viaEngine::new_no_daemon, i.e. two private engines, so what those tests showis that attach state does not survive a new process.
The experiment is cheap and should be run before anything ships: two connections
to one
hyperd; A attaches a file under an alias; B queries that alias andalso enumerates
pg_catalog.pg_database. Land it as a permanentcharacterization test whichever way it resolves, so the documented model has
evidence behind it. If B can see A's attachment, cohort scoping moves from
strongly recommended to mandatory.
Trust boundary — deferred
Trust boundary — deferred. Establishing an authentication and isolation
model for a shared engine is a prerequisite for this work and is being handled
separately outside this issue.
Proposed public API sketch
Additive, and living entirely in
hyperdb-daemon. Note that the connect callsbelow are today's unmodified
hyperdb-apicalls.Callers have three genuinely different intentions and must be able to say which:
The central invariant
A shared engine's
Dropmust release its lease without stoppinghyperd.That is the deliberate asymmetry with
HyperProcess::drop, and it is the singlemost important property in the crate. The test that pins it should assert both
halves so it cannot pass by accident: acquire a shared engine, drop it, prove
the daemon and its
hyperdare still alive and serving; then acquire a privateengine, drop it, prove
hyperdexited.This asymmetry is also why a non-stopping variant of
HyperProcessis anon-goal.
HyperProcess::dropis load-bearing for the test suite —TestConnectionandTestServerboth hold aHyperProcessfield purely sothat scope exit reaps the server — so a non-stopping variant would silently
orphan a server per test. Shared handles must be a distinct type.
Other API notes
Optionsis#[non_exhaustive]deliberately.ChartOptionsinhyperdb-mcpwas found to be source-breaking to extend precisely because it was not, and
this crate will grow knobs.
Sharedreturns a typed error naming why (nodaemon reachable, spawn timed out, protocol unsupported).
SharedOrPrivatesucceeds but reports which mode it got and why the preferred one was
declined, and logs at
warn. The MCP currently logs its fallback atdebugand returns
Ok(None), which is exactly why a ten-second stall in thediscovery path went unnoticed in normal operation (now fixed, fix(mcp): discover() parses the whole DaemonRecord strictly, then discards what the strictness bought #270).
SharedOrPrivatecarries a deadline, sized from measurement, so thepreferring mode is provably not a pessimisation. This is a correctness
property, not a tuning knob.
idle_timeoutshould beSome(_). Today's daemon defaults toNoneand runs forever unless configured. That is right for a product thatwants to stay warm for its user and wrong for a library: a
cargo testrunmust not leave a resident service behind.
failure mode — a client that is alive but idle past the timeout has the engine
shut down underneath it. Clients would register on acquire and release on
drop, with the idle timer running only while the lease count is zero;
heartbeats remain the mechanism that expires leases held by processes that
died without releasing. This needs new control commands, hence a protocol
version bump.
the wrong compatibility axis for a wire protocol, and report_hyperd_error_to_daemon changed its public signature in a patch release with no BREAKING changelog entry #276 is the evidence.
Add an explicit
protocolinteger to the discovery record and the pingresponse; a client supports protocol N and N-1, and a daemon advertising an
unsupported protocol is treated as absent rather than as an error, so the
client starts its own daemon instead of failing.
Before and after
The
Connection::newline changes toConnection::connect; nothing else moves.That pool line is the clearest illustration of the architectural finding:
connection pooling over a shared daemon works today, with no new API,
because
PoolConfig.endpointis already a string and the pool has never owned aprocess.
The benefit is unmeasured, and measuring it should gate the work
The entire premise is that sharing saves startup cost. This repository does
not contain a measurement of
hyperdspawn-to-usable wall clock. Bothbenchmark documents were checked directly:
docs/BENCHMARK_GUIDE.mdmeasures throughput. Its onlyspawnhits arespawn_blockingtask spawns and "task-spawn overhead".docs/hyperd-release-benchmarks.mdmentions "cold-start variance", but incontext that refers to insert-throughput variance, not process spawn.
The honest state is one figure from an adjacent code path and one CI upper
bound:
hyperdb-api-derive/README.md:216hyperdstartup alone, "especially macOS"hyperdb-mcp/tests/daemon_tests.rs:1774No
hyperdmemory figure exists anywhere in the repository — searches findonly benchmark-host RAM totals and a qualitative "reduced memory overhead"
claim in the MCP README.
This is not a reason to abandon the idea. It is a reason not to design against a
number nobody has, because the answer changes the design:
worth doing only for workloads that pay it hundreds of times.
large and the feature is clearly justified.
feature is net-negative for the first process and only ever wins on the
second — still fine, but it changes what the default should be.
How to measure it
HyperProcess::new(None, None)plus one trivial query to first row.20 iterations, report median and p95, release build.
TransportMode::TcpandTransportMode::Ipc).hyperdversion fromhyperdb-bootstrap/hyperd-version.toml, and whether the page cache was warm.already-running daemon — over the same iteration count. The delta between
those two medians is the per-process win, and warm acquisition is the number
the design actually needs, because discovery is not free either.
fallback_deadline: warm hit, dead-port timeout, and a full port scan.hyperdRSS at idle and after a representative query, and total RSSfor N = 1, 4, 16 concurrent processes. Note the interaction:
memory_limitdefaults to 80 % of host RAM and is instance-global, so N private processes
each believe they may use 80 % of the machine — which is an argument for
sharing on memory grounds that nobody has quantified.
--nocaptureand assert nothing, following the existing spike pattern. A wall-clock
assertion is a flaky test on shared CI.
docs/BENCHMARK_GUIDE.md. Do notadd a row to
docs/hyperd-release-benchmarks.md— that file takes a row on ahyperdpin bump or a material API change, and conflating a startupmeasurement with it would make a future engine delta unattributable.
Where it pays off, and where it does not
memory_limit, shared blast radiusThe "actively worse" row is measured, not guessed. From
docs/BENCHMARK_GUIDE.md(Apple M3 Max, 96 GB,hyperd0.0.26479,2026-09-05, medians of 5):
AsyncArrowInserter, 100M rowsquery.full_scan, asyncquery.filtered, asyncThe guide states plainly that "Parallelism no longer helps Arrow inserts" —
single-connection
AsyncArrowInserteroutruns the 4-connection variant, "sospending connections on an Arrow insert buys nothing on this host"
(
docs/BENCHMARK_GUIDE.md:201). Read for this design: onehyperdservesconcurrent readers well and concurrent bulk writers poorly. A shared daemon
whose tenants are all ingesting will contend on exactly the workload where extra
connections already measure negative.
Two caveats on that reading. The guide warns the ×4 rows are
"order-of-magnitude only", with a ±20–61 % spread, because four workers contend
on a 14-core laptop. And the Windows figures invert the insert result
(single-connection
AsyncArrowInserter5.39 M/s versus 20.28 M/s at ×4 overTCP), so the conclusion is host- and engine-version-specific, not universal.
The win is narrower than the original proposal implies. It is concentrated
in many-short-processes-same-application, which is real and worth serving, and
it is absent or negative for the single long-lived process that most library
users actually are.
Prerequisites and related work
hyperdis never recovered. This is aprerequisite, not a follow-on. Reproduced by suspending the managed child
process: a
SELECT 1blocked for over 30 s, the daemon kept seeing theprocess as alive, and never restarted it. Acceptable for a tool a developer can restart; not
acceptable for a library that silently enrolled the caller in a resident
service.
hyperdb-mcp'sdaemon::*be public? Directly related, andextracting the daemon into its own crate would resolve it. The
recommendation recorded on that issue was to narrow the surface, on the
evidence that crates.io reverse dependencies for
hyperdb-mcpare zero,no workspace crate depends on it, and the crate's own
lib.rsalreadydeclares it "not a documented API surface". Extraction gives that surface a
home in a crate whose stated job is exactly that, where it can carry its own
compatibility promise.
work, but relevant if a shared daemon ever grows a single-lock front end.
crash-and-restart tests. Promoting it to a library capability while its
crash-recovery coverage runs on a subset of platforms is not defensible. This
is an argument for elapsed time, not more code.
Recommendation on timing
The user has put the broader feature on the back burner. The one piece with
a real deadline is the extraction.
daemon/*intohyperdb-daemonbefore1.0.0. It removes apublic module from
hyperdb-mcp, which is free before1.0.0and a majorbump after. It is worth doing on report_hyperd_error_to_daemon changed its public signature in a patch release with no BREAKING changelog entry #276's merits alone, independent of
whether any of the rest ever ships, and it is the cheapest piece — a move
plus a re-export decision. Scope discipline matters: it is a move, not a
redesign. The evidence that it worked is the existing daemon test suite
passing unchanged under the new crate, reported as a characterization rather
than dressed up as red-before-green.
hyperdRSS. Cheap, and it gates whether any ofthe rest is justified.
being handled separately.
acquire()as a0.1.0of the new crate, after1.0.0, withPrivateas the default and nosilent fallback.
memory_limitand the shared blast radius make it indefensible, and neitheris fixable in this repository.
The highest-value follow-on, once numbers exist, is teaching the test helpers to
accept a shared engine — but the risk there is the whole phase. Tests
currently get a fresh engine each time, so sharing one means leaked state
between tests. Enumerate what leaks (temp tables, attach aliases, session
settings, memory pressure), prove per-test cleanup, convert one file, measure
the delta, and stop for review before converting the suite.
One
hyperdb-apidecision has a1.0.0deadlineConnection::newtakes a concrete&HyperProcess:If a shared-engine handle should ever be usable where a
HyperProcessisusable, that parameter must become a trait bound. Generalising a concrete
parameter to
impl Traitis source-compatible for ordinary call sites but notfor turbofish or function-pointer uses, so it is cheapest to do — or decide
against — before the freeze.
The recommendation is not to do it.
Connection::connect(endpoint, …)already covers the shared case, and adding a trait purely for symmetry is
speculative generality; adding it later is a minor addition, since a new trait
plus a new inherent method breaks nothing. But it is the only identified
hyperdb-apichange whose cost rises after1.0.0, so it wants an explicithuman confirmation rather than a default.
Non-goals
hyperdbroker.the new crate. That is MCP policy and stays there.
HyperProcess.Privateremains the default andHyperProcess::dropkeeps stopping the process, because the test suitedepends on it.
HyperProcess. Shared handles are a distincttype.
hyperdb-api. Firm repository constraint; the designsatisfies it by adding nothing to
hyperdb-apiat all.gate, not a follow-on.
Open questions needing a human decision
measurement above. This is the real go/no-go.
crate name, or a required explicit argument with no default? A path-derived
default silently splits cohorts when a binary is rebuilt to a different
location; a required argument is more honest but less ergonomic.
under an idle-but-live client, at the cost of new control commands and a
protocol bump. Is that complexity warranted in a first release, or is a
generous idle timeout plus heartbeats enough?
Connection::newbe generalised to a trait before1.0.0?Recommendation: no. Deadline:
1.0.0.daemonfromhyperdb-mcpforcompatibility? Recommendation: no — a re-export preserves the surface the
change exists to remove, and report_hyperd_error_to_daemon changed its public signature in a patch release with no BREAKING changelog entry #276 established no known downstream library
consumer.
hyperdb-mcpkeep its version-takeover behaviour after extraction, ordoes it become a CLI-only operator action? The MCP's binary-upgrade UX
currently depends on it.
have a shared-instance concept? Nothing in this repository describes one,
but that is absence of evidence, and it rests on a grep of this tree. Needs
external verification before "novel" is claimed anywhere public, and
terminology should be aligned rather than invented if a concept already
exists.
Recorded from an offline design exploration and its phased plan, both verified
against the tree on 2026-09-06. The documents themselves are deliberately not
committed. Every code reference above was re-checked against the repository
before being repeated here.