Use Isolated Execution Scopes
An Isolated Execution Scope provides a dedicated, per-connection state for running WATCH/MULTI/EXEC transactions. GLIDE clients normally multiplex commands over shared connections, which means per-connection state like WATCH cannot be held safely for a single caller. A scope gives you an exclusive connection where your WATCH keys remain valid until you call EXEC.
Use isolated scopes when you need optimistic concurrency control (OCC) — for example, incrementing a counter only if no other client modified it, or implementing compare-and-swap patterns.
Acquire a Scope
Section titled “Acquire a Scope”from glide import GlideClient
client = await GlideClient.create(config)
async with await client.scoped_connection(routing_key="my-key") as scope: # Use scope for WATCH/MULTI/EXEC passimport glide.api.GlideClient;import glide.api.models.scope.IsolatedScope;
GlideClient client = GlideClient.createClient(config).get();
try (IsolatedScope scope = client.scopedConnection( Duration.ofSeconds(5), "my-key").get()) { // Use scope for WATCH/MULTI/EXEC}import { GlideClient } from '@valkey/valkey-glide';
const client = await GlideClient.createClient(config);
const scope = await client.scopedConnection('my-key');try { // Use scope for WATCH/MULTI/EXEC} finally { scope.release();}scope, err := client.ScopedConnection(ctx, 5*time.Second, "my-key")if err != nil { log.Fatal(err)}defer scope.Close()// Use scope for WATCH/MULTI/EXECOptimistic Locking with WATCH/MULTI/EXEC
Section titled “Optimistic Locking with WATCH/MULTI/EXEC”The classic optimistic concurrency control pattern works as follows:
- Acquire a scope
WATCHthe key(s) you intend to modify- Read the current value
- Begin a
MULTItransaction - Write the new value
EXEC— returnsnullif any watched key was modified by another client- Retry if
EXECreturnednull
from glide import GlideClient
client = await GlideClient.create(config)
max_retries = 10for attempt in range(max_retries): async with await client.scoped_connection(routing_key="counter") as scope: await scope.watch("counter") val = await scope.get("counter") new_val = str(int(val or "0") + 1) await scope.multi() await scope.set("counter", new_val) result = await scope.exec() if result is not None: break # Success # WATCH conflict — retryimport glide.api.GlideClient;import glide.api.models.scope.IsolatedScope;
GlideClient client = GlideClient.createClient(config).get();
for (int attempt = 0; attempt < 10; attempt++) { try (IsolatedScope scope = client.scopedConnection( Duration.ofSeconds(5), "counter").get()) { scope.watch("counter").get(); String val = scope.get("counter").get(); int newVal = (val != null ? Integer.parseInt(val) : 0) + 1; scope.multi().get(); scope.set("counter", String.valueOf(newVal)).get(); Object result = scope.exec().get(); if (result != null) break; // Success }}import { GlideClient } from '@valkey/valkey-glide';
const client = await GlideClient.createClient(config);
for (let attempt = 0; attempt < 10; attempt++) { const scope = await client.scopedConnection('counter'); try { await scope.watch('counter'); const val = await scope.get('counter'); const newVal = String((parseInt(val ?? '0', 10)) + 1); await scope.multi(); await scope.set('counter', newVal); const result = await scope.exec(); if (result !== null) break; // Success } finally { scope.release(); }}for attempt := 0; attempt < 10; attempt++ { scope, err := client.ScopedConnection(ctx, 5*time.Second, "counter") if err != nil { log.Fatal(err) }
scope.Watch(ctx, "counter") val, _ := scope.Get(ctx, "counter") n, _ := strconv.Atoi(val) newVal := strconv.Itoa(n + 1) scope.Multi(ctx) scope.Set(ctx, "counter", newVal) result, _ := scope.Exec(ctx) scope.Close() if result != "" { break // Success }}Blocking Commands
Section titled “Blocking Commands”Blocking commands honor their own blocking timeout rather than the client’s request timeout, so BLPOP key 0 blocks until an element arrives instead of failing when the request timeout elapses.
If a blocking command does time out or its caller cancels it, the scope’s connection is discarded on release instead of being returned to the pool. A server-side waiter may still be armed on it, and a later borrower could otherwise receive an element it never asked for.
Concurrent Scopes
Section titled “Concurrent Scopes”Each client keeps its own pool of scope connections, holding up to 64 concurrent scopes. Acquiring beyond that limit waits for one to be released rather than failing immediately, so release each scope as soon as its transaction completes — a context manager or try/finally is the safest way to guarantee that. The wait ends at the acquire timeout (default 5 s) with a timeout error; causes that waiting cannot fix, such as a closed parent client or an invalid configuration, fail immediately instead of waiting.
IAM Authentication
Section titled “IAM Authentication”When the parent client is configured for IAM authentication, a scope authenticates as the same IAM identity, and re-authenticates on its next command after the token rotates.
Re-authentication is deferred while it cannot be sent — inside an open MULTI (where it would be queued and corrupt the EXEC reply) — and resumes afterwards. If re-authentication fails, the connection is discarded rather than reused, so a scope never continues on a connection that can no longer authenticate.
Cluster Mode
Section titled “Cluster Mode”When using isolated scopes with a cluster client, keep the following in mind:
Routing Key
Section titled “Routing Key”Pass a routing_key when acquiring the scope. The key is used to determine which cluster node (via CRC16 slot hashing) the scope’s dedicated connection targets. If you omit it, the scope connects to the primary that owns slot 0 and is pinned to the slot of its first keyed command — which is not the same as passing a key that hashes to slot 0, since that constrains the scope to slot 0 from the start.
async with await client.scoped_connection(routing_key="user:123") as scope: await scope.watch("user:123") # ...try (IsolatedScope scope = client.scopedConnection( Duration.ofSeconds(5), "user:123").get()) { scope.watch("user:123").get(); // ...}const scope = await client.scopedConnection('user:123');scope, _ := client.ScopedConnection(ctx, 5*time.Second, "user:123")Same-Slot Requirement
Section titled “Same-Slot Requirement”All keys used within a scope must hash to the same slot. If you attempt a cross-slot operation, the command will be rejected. Use hash tags (e.g., {user:123}:name and {user:123}:email) to ensure related keys land in the same slot.
MOVED Handling
Section titled “MOVED Handling”A scope’s connection is pinned to the node that owned its slot when the scope was acquired. If the slot moves afterwards (for example during a rebalance), commands return a MOVED error rather than being redirected: a scope carries per-connection state such as WATCH keys and queued MULTI commands, and that state cannot follow a redirect to another node.
Treat it like any other scope failure — release the scope and acquire a new one, which connects to the slot’s current owner. The retry loop shown above handles this without special-casing.
Limitations
Section titled “Limitations”The following apply to the preview release.
By design
Section titled “By design”These are intentional constraints for this release. Design your application around them.
- No Pub/Sub: Scoped connections do not support
SUBSCRIBE,PSUBSCRIBE, orSSUBSCRIBE. Entering subscribed mode requires a dedicated message handler that a scope does not set up, so messages could never be delivered. Use the main client for Pub/Sub. - No MOVED/ASK redirects: By design, as described above — the error surfaces to the caller and the scope must be replaced.
- Single slot per scope in cluster mode: A scope is pinned to the slot of its routing key, or, when none was passed, to the slot of its first keyed command. Later commands that route to a different slot are rejected with
CROSSSLOT, matching the server’s requirement thatWATCHand itsMULTI/EXECtarget the same slot. - A scope does not outlive its client: Closing the client, or the pool the client was borrowed from, invalidates any scope still open on it. Further commands on that scope fail.
- No automatic reconnection: If the connection drops mid-scope, the command fails and the scope becomes unusable. Reconnecting silently would return a connection without the
WATCHkeys, queuedMULTIcommands, or tracking registrations the caller is relying on, so anEXECcould commit where it should have aborted. Acquire a new scope instead. - Single caller per scope: Only one coroutine or thread should use a scope at a time. Commands on one scope are serialized, but a scope has a single
WATCH/MULTIstate, so two callers sharing it will see each other’s queued commands and conflicts. Use one scope per caller.
Known issues
Section titled “Known issues”These are being tracked and are expected to be resolved in a future release.
- Resources can leak on cancelled blocking commands: If an unbounded blocking command (for example
BLPOPwith a timeout of0) is cancelled on a scope, the underlying connection may leak. Prefer a non-zero timeout for blocking commands during the preview. - Scope connections keep the password the client was created with:
updateConnectionPassworddoes not reach them — newly acquired scopes still send the old password, and idle scope connections are not re-authenticated. IAM auth is unaffected (scopes use the parent’s live token). Until this is fixed, rotate the password only while the old one is still accepted, or recreate the client. - TLS is not yet enforced for IAM authentication: IAM-authenticated scoped connections are not required to use TLS. Configure TLS explicitly when using IAM auth.