Complete reference for all TiKV RawKV operations supported by the PHP client.
- Basic CRUD Operations
- Batch Operations
- Scanning Operations
- Range Operations
- TTL Operations
- Atomic Operations
- Data Integrity
- Bulk Import (SST Ingest)
Retrieve a value by key:
$value = $client->get('mykey');
// Returns: string value or null if not foundParameters:
key(string): The key to retrieve
Returns: ?string - Value or null if key doesn't exist
Example:
$value = $client->get('user:123');
if ($value !== null) {
$user = json_decode($value, true);
echo "User: {$user['name']}";
} else {
echo "User not found";
}Store a key-value pair:
$client->put('mykey', 'myvalue');Parameters:
key(string): The key to storevalue(string): The value to storettl(int, optional): Time-to-live in seconds (0 = no expiration)
Returns: void
Example:
// Simple put
$client->put('config:theme', 'dark');
// Put with TTL (expires in 1 hour)
$client->put('session:abc123', json_encode(['user_id' => 42]), ttl: 3600);
// Store serialized data
$user = ['id' => 1, 'name' => 'Alice'];
$client->put('user:1', json_encode($user));Remove a key:
$client->delete('mykey');Parameters:
key(string): The key to delete
Returns: void
Example:
// Delete single key
$client->delete('temp:file:123');
// Delete with existence check
if ($client->get('lock:resource') !== null) {
$client->delete('lock:resource');
}Batch operations are not atomic. Keys are grouped by region and each region's keys are split further into sub-batches (at most 512 keys or 16 KB per RPC), then all sub-batches are dispatched concurrently. If any sub-batch fails — after the client's own per-sub-batch retry budget is exhausted — the client throws
BatchPartialFailureException: an unknown subset of the batch has already been applied. Do not assume the exception means "nothing happened", and do not assume it means "everything happened". See Partial Failure and Recovery below.
Retrieve multiple keys efficiently:
$values = $client->batchGet(['key1', 'key2', 'key3']);
// Returns: ['key1' => 'value1', 'key2' => null, 'key3' => 'value3']Parameters:
keys(string[]): Array of keys to retrieve
Returns: array<array-key, ?string> - Associative array of key => value (null for missing keys). Keys come back under PHP's array-key semantics: a canonical decimal-integer key ('1000', '0', '-5') is returned as an int key, everything else (and a canonical decimal beyond PHP_INT_MAX) stays a string key. The lookup is lossless — $values['1000'] finds the entry stored as int 1000 — so cast a foreach key with (string) before passing it to a string-typed parameter. See helpers/faq.md.
Throws: BatchPartialFailureException if any region's sub-batch fails. The call is read-only, so a full retry is always safe. Note that no results are returned on partial failure — keys served by regions that succeeded are discarded and must be fetched again.
Example:
$userIds = ['user:1', 'user:2', 'user:3'];
$users = $client->batchGet($userIds);
foreach ($users as $userId => $userData) {
if ($userData !== null) {
$user = json_decode($userData, true);
echo "$userId: {$user['name']}\n";
} else {
echo "$userId: Not found\n";
}
}Performance Note: BatchGet executes requests to multiple regions in parallel, making it much faster than individual Get calls.
Store multiple key-value pairs:
$client->batchPut(['key1' => 'value1', 'key2' => 'value2']);Parameters:
keyValuePairs(array<string, string>): Associative array of key => valuettl(int, optional): TTL applied to all keys (0 = no expiration)
Returns: void
Throws: BatchPartialFailureException if any region's sub-batch fails.
Non-atomic write. Like
deleteRange,batchPutis not atomic across regions: when the exception is thrown an unknown subset of the key-value pairs has already been written. For a fixed key/value set a full retry is safe — puts are idempotent — but if your application layers non-idempotent logic on top (counters, appends), a blind whole-batch retry may double-apply work. In that case the failed sub-batch's keys are not identifiable through the exception — recompute or issue keys individually (see Partial Failure and Recovery).
Example:
// Store multiple users
$users = [
'user:1' => json_encode(['name' => 'Alice', 'age' => 30]),
'user:2' => json_encode(['name' => 'Bob', 'age' => 25]),
'user:3' => json_encode(['name' => 'Charlie', 'age' => 35]),
];
$client->batchPut($users);
// Store with TTL (all keys expire in 10 minutes)
$cacheData = [
'cache:page:home' => '<html>...</html>',
'cache:page:about' => '<html>...</html>',
];
$client->batchPut($cacheData, ttl: 600);Performance Note: Like BatchGet, BatchPut executes parallel requests across regions.
Delete multiple keys:
$client->batchDelete(['key1', 'key2', 'key3']);Parameters:
keys(string[]): Array of keys to delete
Returns: void
Throws: BatchPartialFailureException if any region's sub-batch fails.
Non-atomic write. As with
batchPut, an unknown subset of the keys may already be deleted when the exception is thrown. Deletes are idempotent (deleting a missing key is not an error), so a full retry of the same key list is safe.
Example:
// Cleanup old sessions
$oldSessions = ['session:abc', 'session:def', 'session:ghi'];
$client->batchDelete($oldSessions);
// Delete by pattern (using scan + batch delete)
$results = $client->scanPrefix('temp:');
$keysToDelete = array_column($results, 'key');
if (!empty($keysToDelete)) {
$client->batchDelete($keysToDelete);
}All three batch operations share the same execution model
(BatchAsyncExecutor):
- Keys are grouped by TiKV region; each region's keys are split into sub-batches of at most 512 keys / 16 KB (one gRPC RPC each).
- Each sub-batch is wrapped in the client's retry executor — transient
region/transport errors are retried automatically within the configured
options['retryDeadlineMs']budget (default 30 s) before it is counted as a failure. - All sub-batches are dispatched concurrently and awaited in order. Errors are accumulated rather than aborting the loop; on the first wait-phase failure the remaining in-flight futures are cancelled and are not awaited.
- If any sub-batch failed,
BatchPartialFailureExceptionis thrown — even if most regions succeeded.
The exception's diagnostics: getRegionErrors() returns
array<int, TiKvException> (failed sub-batch index → exception) and
getTotalRegions() returns the number of sub-batch units in the batch. The
message summarises both: Batch operation partially failed: <n> of <total> regions failed. First error: ....
Note: the array keys in
getRegionErrors()are internal sub-batch indices, not TiKV region IDs and not your keys — the client does not expose which user keys belonged to a failed sub-batch. In practice, treat the batch as "partially applied with unknown affected keys".
Recovery patterns:
use CrazyGoat\TiKV\Client\Exception\BatchPartialFailureException;
try {
$client->batchPut($pairs);
} catch (BatchPartialFailureException $e) {
printf(
"%d of %d sub-batches failed\n",
count($e->getRegionErrors()),
$e->getTotalRegions(),
);
// Idempotent work (fixed key/value set): a full retry is safe and
// simplest — already-written keys are simply written again.
$client->batchPut($pairs);
}If your batch carries non-idempotent logic (counters, appends), you cannot retry blind — the failed sub-batch's keys are not identifiable through the exception. Either:
- issue each key as an individual
put()(each with its own read-decide-write or CAS guard), or - design the operation as one that can be re-derived: e.g. re-read the state
and recompute the whole batch, or wrap the update in a transaction
(
$tikv->transaction()) which is atomic.
batchGet is read-only: after a partial failure simply re-run the whole
call. batchDelete with a fixed key list is idempotent (missing keys are
not an error) and may be retried whole.
For the per-operation verdicts — which operations converge when repeated and which ones must not be repeated after an ambiguous failure — see Idempotency and Retry Safety.
Deadline variant: BatchDeadlineExceededException is thrown by
BatchAsyncExecutor when an explicit wall-clock deadline (the
$deadlineMs argument) expires; in-flight futures are cancelled, so the
applied subset is likewise unknown. No shipped client option currently
wires a batch deadline — built-in batch operations run without one — so you
will only see this exception if you drive BatchAsyncExecutor directly.
Range scan over keys:
$results = $client->scan('startKey', 'endKey', limit: 100, keyOnly: false);
// Returns: [['key' => 'k1', 'value' => 'v1'], ['key' => 'k2', 'value' => 'v2'], ...]Parameters:
startKey(string): Start of range (inclusive)endKey(string): End of range (exclusive)limit(int, optional): Maximum results;0(default) returns the whole range — the client pages internally (RawKvClient::MAX_SCAN_LIMITrows per RPC) and buffers every row, up to the configurableoptions['maxScanRows']guard (checked per fetched page, not per row). See Iterating Large Ranges.keyOnly(bool, optional): Return only keys, no values
Returns: array<array{key: string, value: ?string}>
Example:
// Get the first 100 users (assuming user: prefix)
$users = $client->scan('user:', 'user;', limit: 100);
foreach ($users as $user) {
echo "{$user['key']}: {$user['value']}\n";
}
// Get only keys (faster, no value transfer)
$keys = $client->scan('log:2024-01-', 'log:2024-02-', keyOnly: true);
echo "Found " . count($keys) . " log entries\n";
// limit: 0 returns everything, but holds it all in memory. For a large
// range prefer the lazy iterator, which keeps only one page in memory:
foreach ($client->scanIterator('user:', 'user;') as $row) {
process($row);
}Scan all keys with a given prefix:
$results = $client->scanPrefix('user:', limit: 100, keyOnly: false);Parameters:
prefix(string): Key prefix to scanlimit(int, optional): Maximum results;0(default) returns the whole prefix (paginated internally, guarded byoptions['maxScanRows'], checked per fetched page)keyOnly(bool, optional): Return only keys
Returns: array<array{key: string, value: ?string}>
Example:
// Get the whole prefix into memory (limit: 0 is the default)
$users = $client->scanPrefix('user:');
// Get all products in a category
$products = $client->scanPrefix('product:electronics:');
// Count keys (keyOnly for efficiency)
$keys = $client->scanPrefix('session:', keyOnly: true);
$activeSessions = count($keys);
// For a large prefix prefer the constant-memory lazy iterator:
foreach ($client->scanPrefixIterator('user:') as $key => $value) {
process($key, $value);
}Implementation Note: ScanPrefix is a convenience method that calculates the end key automatically by incrementing the last byte of the prefix.
A bounded scan() / scanPrefix() / reverseScan() call accepts at most
RawKvClient::MAX_SCAN_LIMIT (10240) rows per RPC. Passing limit: 0 does
not cap the result at 10240: the client pages internally (10240 rows per
RPC) and returns the complete range. That is convenient, but the whole result
is still buffered in one PHP array, so an unbounded scan is guarded by
options['maxScanRows'] (default RawKvClient::DEFAULT_MAX_SCAN_ROWS =
100000) — exceeding it throws ScanLimitExceededException rather than
silently truncating. The guard is evaluated per internally fetched page
(after that page is buffered), so it is page-granular, not a strict per-row
bound: a guard below the page size still reads a whole page before throwing,
and getScannedRows() can exceed getMaxRows().
To read a range larger than maxScanRows, or to keep memory flat at all,
use the built-in lazy scan iterator instead of paging manually:
// scanIterator(string $startKey, string $endKey, int $batchSize = 1024, bool $keyOnly = false): ScanIterator
// Constant memory: one page of $batchSize rows is held at a time.
foreach ($client->scanPrefixIterator('user:', batchSize: 500) as $key => $value) {
process($key, $value);
}
foreach ($client->scanIterator('a', 'b', batchSize: 256, keyOnly: true) as $key => $_) {
// $value is null when keyOnly is true
}// scanPrefixIterator(string $prefix, int $batchSize = 1024, bool $keyOnly = false): ScanIterator
// End key computed automatically (same prefix-end logic as scanPrefix()).
foreach ($client->scanPrefixIterator('session:') as $key => $value) {
expire($key, $value);
}Both methods return a CrazyGoat\TiKV\Client\RawKv\ScanIterator that
implements PHP's Iterator (so it works directly in foreach) and behaves as
follows:
- Signatures —
scanIterator($startKey, $endKey, $batchSize = 1024, $keyOnly = false)andscanPrefixIterator($prefix, $batchSize = 1024, $keyOnly = false). The range is[startKey, endKey)(emptyendKey= unbounded); the prefix variant derives the end key via the same prefix-end calculation asscanPrefix(). - Prefetching — each page fetches up to
batchSizerows (the underlyingscan()call).batchSizemust satisfy1 <= batchSize <= 10240; out of range it throwsInvalidArgumentExceptionimmediately when the iterator is created. Larger pages mean fewer RPCs; only one page is ever held in memory. Region routing is served from the region cache, so after the first page a page does not issue an extra PDScanRegionsrequest; PD is only consulted when the cached region chain is incomplete. - Continuation — after consuming a page, the next fetch starts after the
page's last key (
lastKey . "\x00"), the same continuation rule as manual pagination. The scan is exhausted when a page returns fewer thanbatchSizerows. - keyOnly — with
keyOnly: truevalues are not transferred;current()returnsnullfor every entry. - Rewindable — calling
rewind()(e.g. running the sameforeachtwice) restarts the iteration from the original start key and re-scans from the beginning; it does not cache results. - Exceptions —
batchSizeis validated synchronously in the factory call (plusClientClosedExceptionif the client is closed), but the actual scan RPCs happen lazily during iteration:RegionExceptionandGrpcExceptioncan be thrown mid-foreach, and the underlying scans use the same automatic retry logic asscan(). - No reverse iterator — there is no descending counterpart; for reverse
reads use
reverseScan().limit: 0returns the whole range by paging internally (guarded byoptions['maxScanRows'], checked per fetched page); for a very large reverse range either raise that guard deliberately or page in the caller.
See also: Scan Optimization in Advanced Features, and docs/error-handling.md for the full exception table.
Scan in descending order:
// Note the order: the UPPER bound comes first, the LOWER bound second.
$results = $client->reverseScan($upperBound, $lowerBound, limit: 100, keyOnly: false);Parameters:
startKey(string): Upper bound (exclusive) - scan starts below thisendKey(string): Lower bound (inclusive) - scan stops at or above thislimit(int, optional): Maximum results;0(default) returns the whole range (paginated internally and guarded byoptions['maxScanRows'], checked per fetched page)keyOnly(bool, optional): Return only keys
Returns: array<array{key: string, value: ?string}>
Example:
// Get the 10 most recent log entries
// (assuming log keys are timestamp-based like "log:2024-01-15T10:30:00")
//
// reverseScan($startKey, $endKey) scans [$endKey, $startKey) descending, so the
// FIRST argument is the UPPER bound and must sort AFTER the second one. To
// cover every key with a prefix, bump the prefix's last byte: 'log:' -> 'log;'
// (':' is 0x3A, the next byte ';' is 0x3B).
$logs = $client->reverseScan('log;', 'log:', limit: 10);
// Get the last 5 messages for a user, newest first.
// To make the upper bound INCLUSIVE of one specific key, append "\x00" to it:
// that key sorts below its own "\x00"-suffixed form, and nothing else sorts in
// between, so it is the last row returned.
$newest = 'msg:user:123:2024-01-15T10:31:12';
$messages = $client->reverseScan($newest . "\x00", 'msg:user:123:', limit: 5);Important: Reverse scan semantics differ from forward scan:
startKey= upper bound (exclusive) — the first argumentendKey= lower bound (inclusive) — the second argument- Results are in descending order
Passing the bounds the other way round is the most common mistake here: the range is empty, the call succeeds, and you get an empty array with no error. If a reverse scan returns nothing, check that the first argument sorts after the second.
Scan multiple non-contiguous ranges:
$ranges = [
['user:a', 'user:f'], // Users A-F
['user:p', 'user:t'], // Users P-T
];
$results = $client->batchScan($ranges, eachLimit: 50, keyOnly: false);
// Returns: [[results for range 1], [results for range 2], ...]Parameters:
ranges(array<array{0: string, 1: string}>): Array of [startKey, endKey] pairseachLimit(int): Maximum results per range (required)keyOnly(bool, optional): Return only keys
Returns: array<array<array{key: string, value: ?string}>>
Performance Note:
batchScanfans out the ranges concurrently — at mostoptions['maxConcurrency']ranges (default 16; must be>= 1) are in flight at once, and each range's per-region scan RPCs are all dispatched before any response is awaited, so server-side latencies overlap. Results are returned in input range order.
Example:
// Scan specific time ranges
$timeRanges = [
['log:2024-01-01', 'log:2024-01-02'],
['log:2024-01-15', 'log:2024-01-16'],
['log:2024-01-30', 'log:2024-01-31'],
];
$dailyLogs = $client->batchScan($timeRanges, eachLimit: 1000);
foreach ($dailyLogs as $day => $logs) {
echo "Day $day: " . count($logs) . " entries\n";
}Delete all keys in a range:
$client->deleteRange('startKey', 'endKey');Parameters:
startKey(string): Start of range (inclusive)endKey(string): End of range (exclusive)
Returns: void
Example:
// Delete all temporary files
$client->deleteRange('temp:', 'temp;');
// Delete old logs (be careful!)
$client->deleteRange('log:2023-01-01', 'log:2024-01-01');
// Clear a user's data
$client->deleteRange('data:user:123:', 'data:user:123;');Warning: This operation is not atomic across regions. If it fails partway through, some keys may be deleted and others not. The same partial-failure rules as the batch operations apply: deleteRange also throws BatchPartialFailureException, and because deletes are idempotent the whole range operation may simply be re-issued.
Delete all keys with a prefix:
$client->deletePrefix('cache:');Parameters:
prefix(string): Prefix to delete (must not be empty)
Returns: void
Throws: InvalidArgumentException if prefix is empty
Example:
// Clear all cache entries
$client->deletePrefix('cache:');
// Clear specific cache namespace
$client->deletePrefix('cache:api:v1:');
// Delete all sessions for a user
$client->deletePrefix('session:user:123:');Safety: DeletePrefix refuses to delete all keys (empty prefix) to prevent accidents.
Note: TTL requires TiKV to be configured with enable-ttl=true in tikv.toml.
Cluster mode is exclusive.
enable-ttl = trueputs TiKV into V1TTL storage mode, which serves RawKV with TTL but not transactional (TxnKV) requests. A single cluster cannot serve both RawKV-with-TTL and TxnKV.
- RawKV with TTL →
[storage] enable-ttl = true(seetikv.toml)- RawKV without TTL, or TxnKV → leave
enable-ttlunset (seetikv-v1.toml)This project's own E2E suites reflect the split: RawKV tests run against
docker-compose.yml, TxnKV tests againstdocker-compose.yml + docker-compose.txnkv.yml.Changing this setting on a live cluster is an operational migration — plan it before adopting either feature.
Store with automatic expiration:
$client->put('session:123', 'data', ttl: 3600); // Expires in 1 hourParameters:
key(string): Key to storevalue(string): Value to storettl(int): Time-to-live in seconds
Returns: void
Example:
// Session with 2 hour expiration
$sessionData = json_encode(['user_id' => 42, 'login_time' => time()]);
$client->put('session:abc123', $sessionData, ttl: 7200);
// Temporary cache entry (5 minutes)
$client->put('cache:api:result', $apiResponse, ttl: 300);
// Rate limit counter (1 minute window)
$client->put('ratelimit:ip:192.168.1.1', '100', ttl: 60);Check remaining time-to-live:
$ttl = $client->getKeyTTL('session:123');
// Returns: int (seconds remaining) or null (not found/no TTL)Parameters:
key(string): Key to check
Returns: ?int - Seconds remaining, or null if key not found or has no TTL
Example:
$ttl = $client->getKeyTTL('session:abc123');
if ($ttl === null) {
echo "Session not found or expired\n";
} elseif ($ttl < 300) {
echo "Session expires soon ($ttl seconds left)\n";
// Refresh session
$client->put('session:abc123', $data, ttl: 7200);
} else {
echo "Session valid for $ttl seconds\n";
}Apply TTL to batch operations:
$client->batchPut(['k1' => 'v1', 'k2' => 'v2'], ttl: 3600);Per-key TTL is also supported — pass an array of TTLs indexed by key name or positional:
// Per-key TTL (associative array by key name)
$client->batchPut([
'cache:hot' => 'frequently-accessed',
'cache:cold' => 'rarely-accessed',
], ttl: ['cache:hot' => 3600, 'cache:cold' => 60]);
// Per-key TTL (positional array — order must match pairs)
$client->batchPut(['k1' => 'v1', 'k2' => 'v2'], ttl: [3600, 60]);
// Mix of TTL=0 (no expiry) and TTL>0 in the same batch
$client->batchPut(['perm' => 'permanent', 'temp' => 'temporary'], ttl: [0, 300]);Both operations in this section are gated on a client-wide flag that is off by default.
Signatures:
public function setAtomicForCAS(bool $enabled): self; // fluent, returns the client
public function isAtomicForCAS(): bool; // current state of the flagWhat the flag does: with atomic mode enabled the client sets the for_cas
flag on every RawPut/RawDelete request it sends (single-key and batch),
which is what selects TiKV's atomic code path on the store.
Why it defaults to false: the non-atomic path is the faster one for plain
writes, which is what most calls are. The flag is per client and not per call,
so a client that mixes a handful of CAS operations with bulk plain writes pays
the atomic-path cost on every write it sends: atomic mode cannot be paid for
only where CAS is actually needed. When just a few operations need CAS, give
those their own client and leave the bulk client in the fast mode.
When it is required: compareAndSwap() and putIfAbsent() — and therefore
every lock, leader-election and counter pattern built on them — throw
InvalidStateException while the flag is off:
CompareAndSwap requires atomic mode (enable via setAtomicForCAS(true))
How to enable it: once, right after the client is created. It is a client-wide switch, not a per-call argument:
$client = RawKvClient::create(['127.0.0.1:2379']);
$client->setAtomicForCAS(true); // required by compareAndSwap() / putIfAbsent()The flag lives on the client instance: a process that calls close() and
RawKvClient::create() again (reconnect, failover) has to re-apply it. Read it
back with isAtomicForCAS() when a library hands a client to code that may not
have enabled it.
Atomic compare-and-set operation:
Prerequisite:
compareAndSwap()andputIfAbsent()require atomic mode. Call$client->setAtomicForCAS(true)once after creating the client, otherwise both methods throwInvalidStateException. Atomic mode makes the client send thefor_casflag onRawPut/RawDelete, which selects TiKV's atomic code path. It is disabled by default because the non-atomic path is faster for plain writes.
use CrazyGoat\TiKV\Client\RawKv\CasResult;
// Required once per client: compareAndSwap() throws without atomic mode
$client->setAtomicForCAS(true);
$result = $client->compareAndSwap('counter', '5', '6');
// Returns: CasResult objectParameters:
key(string): Key to modifyexpectedValue(?string): Expected current value (null = key should not exist)newValue(string): New value to setttl(int, optional): TTL for the new value
Returns: CasResult with properties:
swapped(bool): True if swap succeededpreviousValue(?string): Previous value (null if key didn't exist)
Example:
// Counter increment
$client->setAtomicForCAS(true); // required by compareAndSwap()
$current = $client->get('counter') ?? '0';
$result = $client->compareAndSwap('counter', $current, (string)($current + 1));
if ($result->swapped) {
echo "Counter incremented to " . ($current + 1);
} else {
echo "Counter changed by another process, retry needed";
echo "Current value: {$result->previousValue}";
}CAS Loop Pattern:
function incrementCounter(RawKvClient $client, string $key): int
{
// Requires atomic mode: the caller must have called
// $client->setAtomicForCAS(true) on this client.
while (true) {
$current = $client->get($key) ?? '0';
$next = (int)$current + 1;
$result = $client->compareAndSwap($key, $current, (string)$next);
if ($result->swapped) {
return $next;
}
// Retry with new value
usleep(1000); // 1ms backoff
}
}Insert only if key doesn't exist:
// Same prerequisite as compareAndSwap(): atomic mode must be on
$client->setAtomicForCAS(true);
$existing = $client->putIfAbsent('lock:resource', 'owner-123');
// Returns: null (success) or existing value (failure)Parameters:
key(string): Key to insertvalue(string): Value to insertttl(int, optional): TTL for the value
Returns: ?string - null if inserted, existing value if key already exists
Example:
// Distributed lock
$client->setAtomicForCAS(true); // required by putIfAbsent()
$owner = 'process-' . getmypid();
$existing = $client->putIfAbsent('lock:resource:123', $owner, ttl: 30);
if ($existing === null) {
echo "Lock acquired\n";
// Do work...
// Release lock
$client->delete('lock:resource:123');
} else {
echo "Lock held by: $existing\n";
}Lock with Heartbeat:
function acquireLock($client, $resource, $owner, $ttl = 30)
{
// Requires atomic mode: the caller must have called
// $client->setAtomicForCAS(true) on this client.
$existing = $client->putIfAbsent("lock:$resource", $owner, $ttl);
if ($existing !== null && $existing !== $owner) {
return false; // Lock held by someone else
}
// Start heartbeat in background
startHeartbeat($client, $resource, $owner, $ttl);
return true;
}
function startHeartbeat($client, $resource, $owner, $ttl)
{
// In real implementation, use a background process or timer
// This is a simplified example
while (hasLock($client, $resource, $owner)) {
sleep($ttl / 2);
$client->put("lock:$resource", $owner, $ttl);
}
}Compute CRC64-XOR checksum over a key range:
use CrazyGoat\TiKV\Client\RawKv\ChecksumResult;
$checksum = $client->checksum('data:start', 'data:end');
// Returns: ChecksumResult objectParameters:
startKey(string): Start of range (inclusive)endKey(string): End of range (exclusive)
Returns: ChecksumResult with properties:
checksum(int): CRC64-XOR checksum valuetotalKvs(int): Total number of key-value pairstotalBytes(int): Total bytes of keys and values
Example:
// Verify data integrity
$before = $client->checksum('backup:data:', 'backup:data;');
// ... perform backup/restore ...
$after = $client->checksum('backup:data:', 'backup:data;');
if ($before->checksum === $after->checksum &&
$before->totalKvs === $after->totalKvs) {
echo "Data integrity verified\n";
} else {
echo "Data mismatch detected!\n";
echo "Before: {$before->totalKvs} keys, checksum {$before->checksum}\n";
echo "After: {$after->totalKvs} keys, checksum {$after->checksum}\n";
}Use Cases:
- Data migration verification
- Backup integrity checks
- Data consistency validation
All operations throw exceptions derived from TiKvException (plus the
standalone InvalidArgumentException, which is outside that hierarchy).
Batch operations (batchGet/batchPut/batchDelete) additionally throw
BatchPartialFailureException — a partially applied batch, not a clean
failure; see Partial Failure and Recovery.
The full class hierarchy, a per-operation exception table for every
RawKvClient and Transaction method, caller-retryability guidance and a
recommended catch order live in the dedicated
Error Handling Reference.
Quick sketch:
use CrazyGoat\TiKV\Client\Exception\TiKvException;
try {
$value = $client->get('key');
} catch (TiKvException $e) {
// Transport, region or retry-budget failure — see the reference for
// which subclass it is and whether your operation may be retried.
echo "TiKV error: {$e->getMessage()}\n";
}Note that InvalidArgumentException is not caught by the arm above — it
does not extend TiKvException. See
Troubleshooting for common errors and solutions.
// Good: Hierarchical, sortable keys
$userId = 123;
$timestamp = date('Y-m-d-H-i-s');
$client->put("user:$userId:log:$timestamp", $logData);
// Good: Prefix for grouping
$client->put('cache:page:home', $html);
$client->put('cache:api:users:list', $json);
// Avoid: Keys that don't sort well
$client->put('user_' . uniqid(), $data); // Random, hard to scan// Good: Small to medium values
$client->put('user:123', json_encode(['name' => 'Alice', 'email' => 'alice@example.com']));
// Avoid: Very large values (TiKV has limits)
// If you need to store large data, consider chunking:
$largeData = str_repeat('x', 10 * 1024 * 1024); // 10MB
$chunks = str_split($largeData, 1024 * 1024); // 1MB chunks
foreach ($chunks as $i => $chunk) {
$client->put("largefile:abc:chunk:$i", $chunk);
}// Good: Reasonable batch sizes (100-1000 keys)
$batch = array_slice($allKeys, 0, 100);
$client->batchPut($batch);
// Avoid: Extremely large batches
// If you have many keys, process in chunks:
$chunks = array_chunk($allKeys, 500);
foreach ($chunks as $chunk) {
$client->batchDelete($chunk);
}Bulk-import key-value pairs into TiKV via SST (Sorted String Table) ingestion:
$client->ingest(['k1' => 'v1', 'k2' => 'v2'], ttl: 3600);Signature: ingest(array $keyValuePairs, ?int $ttl = null): void — $ttl
is in seconds; null means no TTL (the TTL is only sent to TiKV when it is a
positive integer).
How it works (see src/Client/RawKv/SstIngestor.php):
- Keys are sorted client-side (
ksort(..., SORT_STRING)— SST data must be sorted), converted toimport_sstpb.Pairmessages and grouped by region (batch region resolution against PD). - For each region the pairs are streamed to the region leader via the TiKV
ImportSST
RawWriteclient-streaming RPC (1024 pairs per chunk, first message carries the SST metadata: UUID, key range, region epoch, CRC32), and the resulting SST is committed with theIngestRPC.
This bypasses the normal Raft write path and writes pre-sorted data directly
into regions, giving far higher throughput than batchPut() on large loads.
Keys are validated like every other write (non-empty, key/value size limits);
an empty array is a no-op.
Cluster-wide side effect — read before running in production. For the duration of the call every TiKV store in the cluster is switched into import mode (
SwitchMode(Import)via the ImportSST service) and switched back to normal mode on completion. Import mode changes behaviour for every client of the cluster, not just the one that set it. The switch-back runs in afinallyblock, so exceptions are safe — but a killed process (OOM killer, deployment restart,max_execution_time, fatal error) leaves the cluster in import mode with no client-side record and no automatic recovery.Before running
ingest()in production:
- Run it in a dedicated, supervised process with no execution-time limit (CLI,
max_execution_time = 0), never inside a web request.- Run batches in a size you can afford to re-run:
ingest()is not retried automatically — any region error or transport error aborts the remaining regions and throws.- Verify afterwards that all stores are back in normal mode.
Recovering a cluster stuck in import mode: the switch-back is issued to every store PD reports, so the simplest recovery is to re-run a successful
ingest()call (any batch, even small) against the same cluster — itsfinallyblock switches all stores back to normal mode. Alternatively, issue the ImportSSTSwitchMode(Normal)RPC to every store directly (see theimport_sstpb.ImportSST/SwitchModegRPC service); the client'sswitchStoresMode()inSstIngestorshows the exact request shape. A store whoseSwitchMode(Normal)RPC fails during the switch-back is logged asFailed to switch store to normal modeand must be restored manually on that store. (Stores skipped for a rejected address never entered import mode — the import path rejects them too, before any RPC — so they need no restore.)
Operational notes:
- The SwitchMode fan-out to all stores, the
RawWritestream and theIngestRPC each use the ingest gRPC deadline (TimeoutConfig::ingestTimeoutMs, default60000), configurable throughRawKvClient::create()options['timeout']['ingestTimeoutMs'](alongside thereadTimeoutMs,writeTimeoutMs,batchReadTimeoutMs,batchWriteTimeoutMs,scanTimeoutMs,deleteRangeTimeoutMsandchecksumTimeoutMskeys). - Keys that cannot be resolved to a region fail the import with a
TiKvExceptionnaming the redacted key —ingest()never writes a subset of the pairs and reports success (issue #187). - The number of simultaneously in-flight SwitchMode requests is bounded by the
client's
options['maxConcurrency'](defaultRawKvClient::DEFAULT_MAX_CONCURRENCY), so the per-call mode-switch overhead does not scale linearly with store count.
- Getting Started - Basic usage
- Configuration - Client configuration
- Advanced Features - Production patterns
- Troubleshooting - Error handling