Isolation semanticsΒΆ
Moon has three overlapping mechanisms that look like "isolation" from a
distance but each guarantee something narrower than the word implies:
logical databases (SELECT 0..N), workspaces (WS AUTH), and
per-db resource quotas (db-maxmemory). This page states plainly what
each one promises, what it does not, and every limit found during the
WS5b hardening sweep (2026-07). None of these are marketing claims β
if a guarantee isn't listed here, don't assume it holds.
Logical databases (SELECT)ΒΆ
- Guarantee: keys in db N are never visible to a client selected into db M β N via normal KV commands (GET/SET/KEYS/SCAN/etc.).
- Not a security boundary: any authenticated connection can
SELECTto any db (0..--databases). There is no ACL concept of "this user may only touch db 3." Use workspaces (below) or ACL key patterns for real tenant separation. FLUSHDBis whole-db, not workspace-scoped (see Workspaces below) β it clears every key in the selected db, including keys belonging to every workspace that happens to be co-resident in that db. This is pinned bytest_workspace_flushdb_is_whole_db_not_workspace_scopedintests/workspace_integration.rsas intentional, current behavior, not a bug β workspaces layer a key-prefix on top of a shared db, they do not carve out a separate db per workspace.FLUSHALL/FLUSHDBandFT.*indexes: index contents are cleared keyspace-globally (every index, every db) on FLUSHALL/FLUSHDB, while the index definition (FT.CREATE) survives β this matches moon's restart-reload semantics. See "FT.* / vector indexes" below.
Workspaces (WS AUTH)ΒΆ
- Guarantee: once a connection runs
WS AUTH <id>, every key argument it sends is transparently rewritten with a{ws_hex}:hash-tag prefix before dispatch, and stripped back off in KEYS/SCAN/RANDOMKEY/FT.SEARCH responses. Two workspaces with colliding logical key names (user:1in workspace A and workspace B) do not collide in storage or in KEYS output β verified bytest_workspace_keys_no_cross_workspace_leakage. - Composes orthogonally with
SELECT:WS AUTHandSELECTare independent connection-state. A workspace-bound connection can stillSELECTto any db; its keys land in whichever db was selected at write time, still under its{ws_hex}:prefix. This is intentional β workspaces are a keyspace partition, not a db partition β but it meansWS DROP's cleanup must sweep every db, not just db 0, which was a real bug (see below). WS DROPcascade-delete gap (fixed in this branch):WS DROP's best-effort key cleanup (handler_monoio/write.rs,handler_sharded/write.rs,spsc_handler.rs'sWsDropCleanup) only swept db 0 via a hardcodedwith_shard_db(0, ...)call. A workspace connection that everSELECTed to a non-zero db before writing would leak its keys forever afterWS DROPβ they were orphaned, prefixed with a workspace id nothing could everWS AUTHinto again (workspace ids aren't reusable). Found viagit apply -RRED/GREEN TDD withtest_workspace_drop_cleans_keys_across_all_dbs; fixed by sweepings.databases.iter_mut()at all three call sites. This affects every moon release before this fix β operators running workspaces with connections thatSELECTnon-zero dbs should treat priorWS DROPcalls as having potentially leaked keys, recoverable only via manualSCANfor the{ws_hex}:*prefix pattern.WS DROP's all-dbs sweep is synchronous and O(total keys Γ --databases) on the owning shard's event-loop thread. The fix above trades a permanent leak for a full linear scan of every key in every logical db on that shard, run inline (no yield points) duringWS DROP's handling β it blocks that shard thread for the duration, i.e. it stalls every other connection pinned to the same shard while it runs. At the default--databases 16and typical workspace-sized keyspaces this is sub-millisecond and not worth optimizing; it becomes a real latency spike on a shard holding a very large keyspace (millions of keys) combined with a large--databasescount.WS DROPis an admin-rare operation (create a tenant once, drop it once), so this is an accepted trade-off, not scheduled for a fix β flagging it here so a large---databases, large-keyspace deployment doesn't discover it as a surprise production latency blip.FLUSHDBdoes not respect workspace boundaries β see above.- Not a security boundary on its own:
WS AUTHrequires knowing the workspace's UUID v7. There is no password/ACL gate onWS AUTHitself in this release β anyone who can open a connection and knows (or guesses) a workspace id can bind to it. Pair with ACLrequirepass/ TLS client-cert auth for real tenant boundaries; workspaces solve keyspace collision, not authentication.
Per-db resource quotas (db-maxmemory, new in this branch)ΒΆ
- Config surface:
--db-maxmemory <db>:<bytes>(repeatable CLI flag) andCONFIG SET db-maxmemory <db> <bytes>(0 = unlimited, the default for every db).CONFIG GET db-maxmemorylists only the nonzero entries. - Guarantee: when db N's estimated memory is at or above its quota
and the effective eviction policy is
noeviction, writes that would grow db N's memory are rejected with aMOONERR db maxmemory exceedederror, without touching any other db's memory or quota. Sibling dbs are unaffected βneighbor_db_is_unaffected_by_sibling_quotacovers this. Under an eviction policy (allkeys-lru, etc.) db N sheds its own keys to get back under quota instead of rejecting. - Zero cost when unset: the enforcement path is gated by a single
process-wide
AtomicBool(DB_MAXMEMORY_ANY_SET) published whenever--db-maxmemory/CONFIG SET db-maxmemorychange the config; if no db quota is ever configured, every hot-path check is a single relaxed atomic load, mirroring the existing global-maxmemorypre-gate pattern insrc/storage/eviction.rs. SELECT/SWAPDBare exempted from the on-write quota check. Moon's command-metadata table flagsSELECTandSWAPDBas "write-fast" (WF) for ACL/dispatch-classification reasons unrelated to memory growth, which means they flow through the same write-path eviction gate as a realSET. Without an exemption, a connection that fills anoevictiondb to its quota could not evenSELECTaway from that db on the same connection afterward β the gate ran using the pre-switch db index before the SELECT itself updated connection state, so a full db effectively trapped the connection. This was caught by a real-server repro (raw socket script drivingSELECT 1β writes to exhaustion βSELECT 0β observed the db-1 quota error instead of+OK) and fixed viadb_quota::command_exempt_from_db_quota()+check_db_maxmemory_for_command(). A fresh connection starting at db 0 was never affected β this was a same-connection state artifact, not a global lock.- Known, deliberately unfixed twin: the pre-existing global
--maxmemorygate has the identical quirk (SELECT is alsois_write-flagged there) and was NOT touched by this branch β fixing it would mean changing widely-used, shared eviction-gate code well beyond per-db quotas' scope. Filed as a follow-up, not fixed here. MOVEis not covered by the immediate on-write quota check β aMOVEinto a quota'd db does not re-check that db's quota synchronously (the write-path gate only covers the originating db of the command being dispatched). The periodic background sweep insrc/shard/timers.rs::run_eviction()(runs every eviction tick, gated by the same zero-cost atomic) catches this lazily β a db that drifts over quota viaMOVE/SWAPDBgets reconciled on the next tick, not instantaneously.- No disk-offload spill integration: db-quota eviction always
deletes the victim key outright (mirrors
eviction::evict_one_with_spillcalled withNonefor the spill sender). Global--maxmemoryeviction can spill to disk-offload storage when configured; per-db quota eviction cannot. A db under quota pressure withallkeys-lruwill lose data it could otherwise have kept cold-tiered under the global gate. Document this before recommending db-quotas as a substitute for disk offload. - Eviction candidate sampling is a pre-existing, unrelated
limitation:
eviction::sample_random_keys/find_victim_randomuses a fixed 1-sample/8-attempt retry budget regardless of the configuredmaxmemory-samples. At very low live-key counts underallkeys-randomthis can return an OOM error even though technically-evictable keys remain, because the bounded retry gives up first. Not introduced or fixed by db-quota work; noted here because it is easy to mistake for a db-quota bug when writing tests against a small dataset. - Non-inline write commands were bypassing the quota gate entirely
(fixed):
--maxmemory 0combined with no disk-offload spill sender (e.g.--disk-offload disable) meanthandler_monoio'sbatch_eviction_active(and the cross-shard-leg twinspsc_handler'sevict_active) never ran the write-eviction-gate call at all for any command other than the byte-level inline GET/SET fast path β so HSET, LPUSH, SADD, ZADD, INCR, APPEND, MSET, SET-with-options, RESTORE, and every other non-inline write silently ignored a configured db quota. Found via adversarial review, fixed by addingdb_quota::db_maxmemory_any_set()to both conditions (mirroring the Lua bridge's gate, which already had this term). Covered bytest_quota_rejects_non_inline_writes_without_spill_senderintests/db_maxmemory_quota.rs. - Container-growth memory accounting gap (WS6, fixed):
used_memoryused to be charged once, at key-creation time, for an empty container's fixed overhead (entry_overhead()insrc/storage/db.rs, called immediately afterEntry::new_hash()/equivalent, before any fields/elements were inserted). Every subsequent mutation of that SAME key βHSET'smap.insert(field, value), and the equivalent direct-collection-mutation pattern in List/Set/ZSet write commands β never updatedused_memoryagain, so growing one hash key's fields never grew its accounted memory, defeating both the global--maxmemorygate and the per-db quota above identically. Fixed via O(1) per-mutation delta accounting (Database::charge_memory/credit_memory, plus per-container byte-cost helpershash_field_cost/list_elem_cost/set_member_cost/zset_member_costnext toentry_overheadinsrc/storage/db.rs) rather than a full recompute per command β recomputingRedisValue::estimate_memory()on every mutation would turn an O(1) HSET into O(n) on a large hash. The listpack/intset compact encodings use before/afterestimate_memory()snapshots instead (already O(1) there β capacity-based). Covered bytests/container_growth_memory_accounting.rs(HSET/LPUSH growth trips--maxmemory; HDEL correctly credits deleted fields back) plus unit tests in each container command family'smod.rs. The eviction loop's before/afterestimated_memory()delta arithmetic needed no changes β it was already reading the (now-correct) live accumulator. - Self-inflicted write lockout (WS6, fixed β adversarial review
2026-07-08, HIGH): making container growth visible to
used_memory(above) made a second, previously-unreachable bug reachable:run_write_eviction_gate/check_db_maxmemory_for_commandapplied the noeviction reject to every write command uniformly, so once a key's growth tripped--maxmemoryor a db's--db-maxmemoryquota, a pure-shrink command on that SAME key or db βHDEL,SREM,LPOP,ZREM, ... β was also rejected. A tenant that grew a key past the boundary had no self-recovery path short ofFLUSHALLor a restart, undermining the WS5b per-db-quota guarantee. Fixed with a static, provably shrink-only command classification (db_quota::is_shrink_only_commandinsrc/storage/db_quota.rs), mirroring Redis'sCMD_DENYOOMsemantics:DEL/UNLINK,HDEL/HGETDEL,SREM/SPOP,LPOP/RPOP/LREM/LTRIM/LMPOP/BLMPOP,ZREM/ZPOPMIN/ZPOPMAX/ZMPOP/BZPOPMIN/BZPOPMAX/BZMPOP/ZREMRANGEBYSCORE/ZREMRANGEBYRANK/ZREMRANGEBYLEX,GETDEL,EXPIRE/PEXPIRE/EXPIREAT/PEXPIREAT/PERSIST,FLUSHDB/FLUSHALLbypass the reject from both the global maxmemory gate and the per-db quota gate (eviction is still attempted first β an evicting policy may as well reclaim while the write lock is held; only the reject is skipped). Deliberately conservative allow-list β commands that can grow a destination key (LMOVE/SMOVE/COPY/RESTORE/any*STOREvariant) or aren't statically classifiable (SET, even with a shorter value) are excluded. Applied at all three connection-handler call sites (handler_monoio::run_write_eviction_gate, the inline block inhandler_sharded, and both inline blocks inhandler_single). Covered bytest_hdel_self_recovery_past_maxmemory_boundaryandtest_hdel_self_recovery_past_db_maxmemory_boundaryintests/container_growth_memory_accounting.rsβ each grows a key past its cap (asserting the growing write IS rejected), then asserts anHDELon that same over-cap key succeeds, then asserts a follow-up write succeeds once back under budget.
FT.* / vector indexes and workspaces (handoff note β WS5a scope)ΒΆ
This branch (WS5b) does not modify src/vector/,
src/command/vector_search/, or any FTS code β that surface is owned
by the concurrent WS5a workstream (db-scoped FT indexes). What follows
is an observational finding for WS5a to fold in, not a WS5b fix:
- As of this writing,
FT.*indexes are keyspace-global, not workspace-scoped or (pre-WS5a) db-scoped:FLUSHALL/FLUSHDBclear every index's contents regardless of which db or workspace triggered the flush (see theVector Searchsection of the project rootCLAUDE.md, "FLUSHALL/FLUSHDB/HDEL keyspace parity"). A workspace'sWS AUTH-injected key prefix does reachFT.SEARCH/auto-indexing (the prefix is applied before dispatch like any other command), so two workspaces indexing hashes under logically-identical field names do get distinct, non-colliding entries keyed by their distinct prefixed keys β but they share the same index definition and segment set. There is currently no notion of "workspace A's FT index" vs "workspace B's FT index" as separate objects; a workspace-scopedFT.DROPINDEXorFT.SEARCHcannot avoid touching sibling workspaces' documents in the same index, andFT.INFO num_docsreports the combined total across all workspaces. - Recommendation for WS5a: if db-scoped FT indexes land, the natural
follow-up is workspace-scoped indexes gated the same way per-db quotas
are β an explicit index-creation-time association plus a cheap
zero-cost-when-unused check, not a blanket prefix-filter over search
results (which would break HNSW/TQ recall accounting per
CLAUDE.md's vector search notes on segment-levelnum_docs). - No code changes were made on the WS5b side to accommodate or preempt this; it is purely an observation for the other workstream.
Summary tableΒΆ
| Mechanism | Guarantees | Does NOT guarantee |
|---|---|---|
SELECT (logical db) |
Keys in db N invisible to db M via normal KV ops | Auth/ACL boundary; FLUSHDB still whole-db |
Workspaces (WS AUTH) |
No keyspace collision between workspaces, even same db | Auth boundary (no password on WS AUTH); not FLUSHDB-safe; FT.* indexes not workspace-scoped |
db-maxmemory quota |
Per-db memory ceiling, independent of sibling dbs, zero-cost when unset, covers ALL write commands (inline and non-inline alike, including RESTORE) | Not spill-integrated; MOVE/SWAPDB reconciled lazily not synchronously; shares the SELECT-exemption quirk with global maxmemory (both now fixed for db-quota, global left as-is); does not see memory growth from mutating an EXISTING Hash/List/Set/ZSet key (pre-existing, systemic, also affects global --maxmemory) |