Visitar URL original
On the Redis wire a key starting with `$` is the same key as the one without it: GET, SET, GETDEL, EXISTS and INCR alias `<db>.$k` onto `<db>.k`, so one write overwrites another key · Issue #10694 · ArcadeData/arcadedb · GitHub
Skip to content

On the Redis wire a key starting with $ is the same key as the one without it: GET, SET, GETDEL, EXISTS and INCR alias <db>.$k onto <db>.k, so one write overwrites another key #10694

Description

@ruispereira

Summary

RedisNetworkExecutor hands a database-prefixed key straight to the engine's global-variable map:

if (resolved.database() != null)
  return resolved.database().setGlobalVariable(resolved.key(), value);

and every one of LocalDatabase.getGlobalVariable, setGlobalVariable, setGlobalVariableIfAbsent,
setGlobalVariableIfPresent, computeGlobalVariable opens with:

if (name.startsWith("$"))
  name = name.substring(1);

That strip exists for SQL, where $name and name are two spellings of one variable and the $ is syntax.
A Redis key is not syntax, it is opaque client data, and $ is a perfectly ordinary byte in it. So the
Redis wire has a key space in which k and $k are the same key, "" and $ are the same key, and
nothing tells the client.

The broken rule: a wire protocol's key space is the client's, and the server may not rewrite a name in
it.
The place the rule is stated is SQLQueryEngine.validateVariableName, which the same five methods
call right after the strip - i.e. the map is explicitly a SQL variable namespace, and the Redis wrapper
stores arbitrary user keys in it anyway.

Consequences, in order of how badly they fail:

  1. one key silently overwrites another (SET <db>.sess A then SET <db>.$sess B leaves one value);
  2. GETDEL <db>.$k deletes <db>.k;
  3. INCR <db>.$ctr increments <db>.ctr;
  4. SET <db>. v and SET <db>.$ v are one key, so a trailing-dot key is reachable under two names.
    None of them reports an error, which is what makes it worse than a refusal.

Where (HEAD 98f15fe)

  • redisw/src/main/java/com/arcadedb/redis/RedisNetworkExecutor.java - resolveKeyAndDatabase(),
    getVariable(), setVariable(), setVariableIfAbsent(), setVariableIfPresent(),
    computeVariable(), removeVariable(), containsVariable(): all pass the client's key through unchanged
  • engine/src/main/java/com/arcadedb/database/LocalDatabase.java - the name.startsWith("$") strip in
    getGlobalVariable, setGlobalVariable, setGlobalVariableIfAbsent, setGlobalVariableIfPresent,
    computeGlobalVariable

Repro

A server with the Redis plugin and a database catalogdb, one redis-cli per command:

SET  catalogdb.sess  user-A      -> OK
SET  catalogdb.$sess user-B      -> OK
GET  catalogdb.sess              -> user-B      <- the other client's write
GET  catalogdb.$sess             -> user-B

SET  catalogdb.ctr   5           -> OK
INCR catalogdb.$ctr              -> 6
GET  catalogdb.ctr               -> 6           <- a different key was incremented

SET  catalogdb.empty E           -> OK
SET  catalogdb.      DOT         -> OK
GET  catalogdb.                  -> DOT
GET  catalogdb.$                 -> DOT         <- "" and "$" are one key

GETDEL catalogdb.$k1             -> two
GET    catalogdb.k1              -> (nil)       <- deleted through the other name

Real Redis keeps sess and $sess apart, and GETDEL $k1 leaves k1 alone.

Suggested fix

Do not store a client's key in the SQL variable namespace. The narrow fix is for the Redis wrapper to hold
its own per-database ConcurrentHashMap<String, Object> - the same structure defaultBucket already is for
the unprefixed keys - so a Redis key is never rewritten and the two halves of the key space behave alike.
If the global-variable map has to stay the storage, the wrapper must escape the key on the way in and
unescape it on the way out, in ONE place that both the read and the write path call.

The guarantee that stops it coming back: a round-trip property test over the Redis key space - for a
set of keys including $k, k, "", $, $$k, a.b, non-ASCII and the maximum length, assert that
writing a distinct value to each and reading them all back yields exactly what was written, for the
database-prefixed form AND the connection-local form. That one test also pins the two halves of the key
space against each other, which is the second half of the defect here.

Scope / confidence

Reproduced end to end against a running server with redis-cli. Refuted the obvious alternatives: it is not
redis-cli quoting (the shell passes $sess single-quoted, and the server's own reply shows the aliased
value), and it is not the connection-local bucket (the unprefixed form keeps the two names apart, which is
how I know the rewriting is the database path's). I also checked that nothing in the Redis wrapper itself
rewrites the key: it passes resolved.key() through verbatim, so the strip is the engine's.
Not new: the strip predates the current Redis work. That performance work moved
GET/SET/GETDEL/EXISTS/INCR*/DECR* into UNGATED_COMMANDS on the grounds that they touch only
"keys held in memory", which is accurate - I checked setGlobalVariable and it is a pure in-memory map, so
that change is clean.

Activity

  1. added
    engineCore engine: document/schema API
    redisRedis wire protocol module (redisw)
    severity:majorA feature is broken or returns wrong results, with no practical workaround
    on Oct 23, 2026
  2. added this to the 26.11.1 milestone on Oct 23, 2026
  3. ruispereira commented on Oct 26, 2026

    @ruispereira
    ContributorAuthor

    Correction: our refutation section in this issue overstates, and the fix is wider than we implied.

    We wrote that nothing in the Redis wrapper itself rewrites the key. That is true of RedisNetworkExecutor and false of the module. RedisQueryEngine.normalizeRamKey and normalizeRamReadKey strip the same $ deliberately, with a javadoc citing the review of PR #8309.

    Two consequences:

    The aliasing reaches a second surface, so a fix applied only to the wire executor leaves the redis query language answering the old way, and the two surfaces of the same database then disagree about which key a name refers to.

    The module does have a place where this decision is already written down, which is better news than our text suggested: whatever rule you settle on has one existing home rather than needing a new one.

    Re-validated at main 888b83c, still reproducing on both halves:

    SET catalogdb.$sess user-B ; GET catalogdb.sess   -> user-B
    GETDEL catalogdb.$k1                              -> also deletes catalogdb.k1
    
  4. self-assigned this
    on Oct 27, 2026
  5. added a commit that references this issue on Oct 27, 2026
  6. added 2 commits that reference this issue on Oct 27, 2026
  7. removed
    in progressThe team is actively working on this issue
    on Oct 28, 2026
  8. robfrank commented on Oct 28, 2026

    @robfrank
    Collaborator

    Fixed in #10791. On the database-backed Redis key space, <db>.$k and <db>.k were the same key because the SQL variable map strips one leading $. Keys are now escaped in one place so the map is keyed by the client's key verbatim, and the redis query language uses the same rule. Keys still remain SQL variables (SET <db>.k is $k in SQL).

    Still open: #10790 (keys named parent, current, depth, path, stack, history are refused as reserved SQL variables; needs a design decision).


    ❤️ Thanks for helping make ArcadeDB better! ArcadeDB is free and open source, sustained by its community. If it's useful to you or your company, please consider becoming a sponsor to keep development going.

  9. added 2 commits that reference this issue on Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugengineCore engine: document/schema APIredisRedis wire protocol module (redisw)severity:majorA feature is broken or returns wrong results, with no practical workaround

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions