Skip to content
crazy-goatPublic

About

PHP client for TiKV RawKV API via gRPC — CRUD, batch ops, scanning, TTL, atomic operations, TLS, region caching

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

532 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TiKV PHP Client

CI

PHP client for TiKV's RawKV and TxnKV APIs, using the gRPC extension.

Requirements

  • PHP >= 8.2
  • ext-grpc — the PECL gRPC extension, 1.57 or newer. It is a hard requirement: Composer refuses to install the package without it. See Installation for the per-platform instructions.
  • TiKV cluster — default (V1) mode for TxnKV, or [storage] enable-ttl = true for RawKV TTL. The two are mutually exclusive; see Transactions (TxnKV).

Quick Start

# Start TiKV cluster and run example
make up
make example

Installation

The package is not published on Packagist yet, so Composer has to be told where to find it. Add the repository to the repositories section of your project's composer.json:

{
    "repositories": [
        {
            "type": "vcs",
            "url": "https://github.com/crazy-goat/tikv-php"
        }
    ]
}

Then require the package, using the constraint from Which version to require:

composer require crazy-goat/tikv-client:~0.14.0

The client speaks gRPC through the ext-grpc PHP extension, so the requirement is declared in composer.json ("ext-grpc": ">=1.57"). Composer checks it at install time and refuses to install the package on a machine without the extension:

crazy-goat/tikv-client <version> requires ext-grpc >=1.57 -> it is missing from your system. Install or enable PHP's grpc extension.

<version> is the version of the package you install. Contributors working on this repository see the root-package wording of the same error instead:

Root composer.json requires PHP extension ext-grpc >=1.57 but it is missing from your system. Install or enable PHP's grpc extension.

The minimum version (1.57) matches the grpc/grpc library the client depends on and covers every extension API it uses (Grpc\Call::startBatch(), Grpc\Timeval, Grpc\Channel, the OP_* call-operation constants).

Installing the extension

Debian / Ubuntu

PECL compiles the extension, so the build tools come first:

sudo apt-get install php-dev build-essential autoconf
sudo pecl install grpc
PHP_VERSION=$(php -r 'echo PHP_MAJOR_VERSION, ".", PHP_MINOR_VERSION;')
echo "extension=grpc.so" | sudo tee "/etc/php/$PHP_VERSION/mods-available/grpc.ini"
sudo phpenmod grpc

The php-grpc distribution package comes from the sury.org PHP repository, which is not part of stock Debian or Ubuntu. When that repository is already configured, the package works too:

sudo apt-get install php-grpc

Alpine

apk add php83-pecl-grpc

macOS (Homebrew)

pecl install grpc
echo "extension=grpc.so" >> "$(brew --prefix)/etc/php.ini"

Verify

php -m | grep grpc
php -r 'echo phpversion("grpc"), PHP_EOL;'

The reported version must be 1.57 or newer. Restart PHP-FPM (or your web server) after enabling the extension.

Documentation

The full documentation lives in docs/ — the documentation index links every guide.

For TxnKV, this README's Transactions section is the entry point; docs/error-handling.md carries the full per-exception retryability matrix and Transaction Operations covers where each transactional exception originates.

Stability and versioning

The library is pre-1.0. It follows Semantic Versioning, but before 1.0.0 a breaking change bumps the minor version — so a 0.x release may break your code, and 0.14.0 → 0.15.0 is not a safe upgrade on its own.

CHANGELOG.md marks every breaking change of the public API below with a BREAKING prefix and a migration note. A change that breaks a class outside that surface is announced as Breaking change: with a migration note and needs no version bump of its own.

Which version to require

The current release is v0.14.0. Allow patch releases inside the current minor line only:

{
    "require": {
        "crazy-goat/tikv-client": "~0.14.0"
    }
}

That constraint resolves to >=0.14.0 <0.15.0, so it never pulls 0.15.0 — and therefore never pulls a breaking change. Use "0.14.0" instead when you want a reproducible build.

Avoid the constraints that do admit the next minor: ^0, 0.*, * and ~0.14 all resolve to a range that includes 0.15.0 and every later release.

The versions above follow the releases: every one of them is updated in the same pull request as the changelog section of the release, because the guard test reads the latest released version from CHANGELOG.md and fails until every version of this section names the current release or the one that follows it. The step is on the release checklist.

Public API

These are covered by the stability promise: they may only change in a way that keeps existing, correct code working.

Surface What it covers
RawKv\RawKvClient every public method — the RawKV entry point
TxnKv\TxnKvClient every public method — create(), begin() (with its pessimistic, priority, enable1Pc, enableAsyncCommit and eagerPessimisticLocks options), beginOptimistic(), beginPessimistic(), holdGcSafePoint(), releaseGcSafePoint(), getClusterId(), healthCheck(), getMetrics(), pdClient(), reapIdleChannels(), refreshRegionCache(), preloadRegions(), close() — the TxnKV entry point
TxnKv\Transaction every public method — the transaction handle
RawKv\CasResult, RawKv\ChecksumResult, RawKv\KeyTtl the result DTOs of compareAndSwap(), checksum() and getKeyTTLResult()
RawKv\ScanIterator the iterator returned by scanIterator() and scanPrefixIterator()
TxnKv\TransactionStatus the enum returned by Transaction::getStatus()
Exception\* TiKvException and every subclass, NotLeaderHint (the value object behind RegionException::$notLeader), plus InvalidArgumentException (which extends PHP's \InvalidArgumentException, not TiKvException)
TxnKv\Exception\* the seven transaction exceptions: DeadlockException, LockWaitTimeoutException, TransactionConflictException, TransactionTooLargeException, TxnAbortedByGcException, TxnRetryableException, UndeterminedCommitException
Retry\RetryBudgetExhaustedException thrown when the internal retry budget is exhausted
Retry\ErrorKind the enum behind RegionException::$errorKind — the typed variant of a region error
Observability\MetricsInterface the metrics backend you implement and pass as options['metrics']
Observability\DetailedMetricsInterface the optional extension of MetricsInterface for backoff durations, per-store outcomes, lock-resolution results and 2PC phase timings
Observability\InMemoryMetrics the ready-made in-process backend, for tests and benchmarks
Observability\PrometheusMetrics the dependency-free Prometheus text-format exporter
Observability\TraceEventInterface the structured tracing callback you pass as options['tracer']
Observability\TraceCategory the category constants carried by trace events
Observability\InMemoryTracer the in-process trace-event recorder, for tests and diagnostics
Region\ReplicaReadMode, Region\ReplicaReadPolicy the values you pass as options['replicaRead']
the documented options[] keys every key documented in the configuration guide — tls, timeout, metrics, tracer, apiVersion, keyspace, replicaRead, batchCommands, maxScanRows, maxConcurrency, tsoPoolSize, …

The namespaces above are relative to CrazyGoat\TiKV\Client\. No generated protobuf or gRPC class is part of the promise: a message such as Errorpb\NotLeader reaches consumers only through a library-owned value object (Exception\NotLeaderHint for RegionException::$notLeader).

Internal

Every other class of src/Client/ is internal: it may change in any release, with no version bump and no BREAKING note, because it is not part of the contract.

  • the namespaces Batch\, Cache\, Codec\, Connection\, Grpc\, Tls\, Util\;
  • Region\ — except Region\ReplicaReadMode and Region\ReplicaReadPolicy, which the table above covers;
  • Observability\NoOpMetrics — the default backend getMetrics() returns
  • Observability\NoOpTracer — the default tracing callback when no options['tracer'] is configured; write against Observability\TraceEventInterface, which is covered;
  • Observability\DebugLogging — the internal probe that decides whether a logger can emit a debug record when no options['metrics'] is configured; write against Observability\MetricsInterface, which is covered;
  • Observability\SafeLogging — the guard that keeps a logger failure on a close or destructor path from escaping the destructor;
  • RawKv\Dto\ (the RegionInfoMapper helper) and RawKv\RawKvSplitter;
  • the collaborators behind the entry points: RawKv\RawKvCrud, RawKv\RawKvAtomic, RawKv\RawKvBatch, RawKv\RawKvScanner, RawKv\RawKvRangeOps, RawKv\SstIngestor, TxnKv\TxnReader, TxnKv\TwoPhaseCommitter, TxnKv\LockResolver, TxnKv\TransactionState, TxnKv\RollbackRegroupSignal, TxnKv\ResolvedTxnStatusCache, TxnKv\ResolvedLockTracker;
  • Retry\ — except Retry\RetryBudgetExhaustedException and Retry\ErrorKind, which the table above covers: Retry\Backoff, Retry\BackoffType, Retry\ErrorClassifier, Retry\RetryBudget, Retry\RetryExecutor and Retry\RetryVerdict;
  • the generated protobuf and gRPC classes under src/Proto/ — no generated class is part of the promise.

The advanced extension points — Retry\RetryExecutor, Retry\RetryVerdict and Retry\BackoffType in Custom Retry (Advanced), and Tls\TlsConfigBuilder — are documented, but they are internal like the rest: they change whenever the internals they wrap change.

Usage

Basic CRUD Operations

use CrazyGoat\TiKV\Client\RawKv\RawKvClient;

$client = RawKvClient::create(['127.0.0.1:2379']);

// Basic CRUD
$client->put('key', 'value');
$value = $client->get('key');       // 'value'
$client->delete('key');

// Batch operations
$client->batchPut(['k1' => 'v1', 'k2' => 'v2', 'k3' => 'v3']);
$values = $client->batchGet(['k1', 'k2', 'k3']);
$client->batchDelete(['k1', 'k2', 'k3']);

$client->close();

Scanning

// Range scan [startKey, endKey). limit: 0 (the default) returns the whole
// range: the client pages internally at MAX_SCAN_LIMIT (10240) rows per RPC
// and buffers every row, guarded by options['maxScanRows'] (default 100000).
$results = $client->scan('start', 'stop', limit: 100);
// Returns: [['key' => 'k1', 'value' => 'v1'], ['key' => 'k2', 'value' => 'v2'], ...]

// Prefix scanning: same semantics — limit: 0 pages at 10240 rows per RPC and
// buffers the whole result up to options['maxScanRows'].
$results = $client->scanPrefix('user:');

// Lazy scan iterators — constant memory, auto-paginating (page of
// $batchSize rows at a time; batchSize must be 1..10240, default 1024).
// Use these instead of scanPrefix()/scan() when a range may be large.
foreach ($client->scanPrefixIterator('user:', batchSize: 500) as $key => $value) {
    process($key, $value);
}
// $value is null when keyOnly is true
foreach ($client->scanIterator('a', 'b', batchSize: 256, keyOnly: true) as $key => $_) {
    // ...
}

// For an unbounded scan that matches more than options['maxScanRows'] rows
// (default 100000) the client throws ScanLimitExceededException rather than
// silently truncating; the iterators above have no such limit.
//
// A scan never truncates silently: an unbounded scan that hits its cap
// throws, and a bounded scan (limit > 0) returns at most `limit` rows —
// compare the returned count with the limit to see whether the range may
// hold more.

// Reverse scan (descending order)
// Note the order: startKey = UPPER bound (exclusive), endKey = LOWER bound
// (inclusive) — the first argument must sort after the second. Bump the last
// byte of a prefix to cover all of its keys ('log:' -> 'log;');
// RawKvSplitter::calculatePrefixEndKey('log:') computes that end key safely
// (including a trailing 0xFF). Append "\x00" to a key to make the upper bound
// inclusive of that key.
$results = $client->reverseScan('log;', 'log:', limit: 100);

// Scan multiple non-contiguous ranges. The client groups the ranges by
// region and sends one RawBatchScan request per region, so the RPC count is
// the number of regions touched rather than one per range. Requests to
// distinct regions are dispatched concurrently up to
// options['maxConcurrency']. eachLimit is required and must be > 0; each
// range returns at most eachLimit rows. Overlapping ranges, and a region
// error or transport failure, fall back to the per-range RawScan path.
// Returns one list per range: a list input keeps its order, while
// non-sequential keys are re-indexed in ascending key order.
$results = $client->batchScan([['a:', 'a;'], ['b:', 'b;']], eachLimit: 50);

Range Operations

// Delete all keys in range [startKey, endKey)
$client->deleteRange('temp:', 'temp;');

// Delete all keys with a given prefix
$client->deletePrefix('cache:');

TTL (Time-To-Live)

Requires enable-ttl=true in tikv.toml configuration.

Cluster mode is exclusive. enable-ttl = true puts 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 (see tikv.toml)
  • RawKV without TTL, or TxnKV → leave enable-ttl unset (see tikv-v1.toml)

This project's own E2E suites reflect the split: RawKV tests run against docker-compose.yml, TxnKV tests against docker-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 expiration (TTL in seconds)
$client->put('session', 'data', ttl: 3600);        // expires in 1 hour

// getKeyTTL() returns ?int and collapses two cases: null means either that
// the key does not exist or that it exists without an expiry.
$remaining = $client->getKeyTTL('session');

// getKeyTTLResult() keeps the cases apart:
//   missing key       -> exists: false, ttlSeconds: null
//   present, no TTL   -> exists: true,  ttlSeconds: null
//   present, with TTL -> exists: true,  ttlSeconds: int > 0
$result = $client->getKeyTTLResult('session');

Atomic Operations

Note: Compare-And-Swap (CAS) and Put-If-Absent require atomic mode to be enabled via setAtomicForCAS(true). In atomic mode, the underlying RawPut RPC sends the for_cas flag, which tells TiKV to use the atomic code path. Atomic mode is disabled by default for performance — the non-atomic code path is faster for regular writes. Enable it only when you need CAS semantics.

Scope: setAtomicForCAS(true) is a switch of the client instance. It affects all put and delete writes of that instance: put(), delete(), batchPut() and batchDelete() send for_cas. compareAndSwap() and putIfAbsent() require the flag. deleteRange(), deletePrefix() and ingest() do not send it. The atomic path (and its cost) applies to all put and delete writes of that client. If only a part of your workload needs CAS, use a separate client instance for it. Use the same mode for every writer of the same keys. See Atomic Mode.

A transport failure on the CAS request itself (DEADLINE_EXCEEDED, UNAVAILABLE, a connection reset) leaves the outcome indeterminate and throws CasIndeterminateException, with the original GrpcException as getPrevious(). It is never retried, because repeating a CAS can turn a successful first attempt into a false "not swapped". Re-read the key to decide; a plain GrpcException on compareAndSwap()/putIfAbsent() means the request was never sent or the server rejected it before applying it, so the swap definitively did not happen.

use CrazyGoat\TiKV\Client\RawKv\CasResult;

// Enable atomic mode for CAS operations
$client->setAtomicForCAS(true);

// Compare-And-Swap (CAS)
$result = $client->compareAndSwap('counter', '1', '2');
if ($result->swapped) {
    echo "Value was swapped from '1' to '2'";
    echo "Previous value: " . ($result->previousValue ?? 'null');
}

// Put if key does not exist (distributed lock pattern)
$existing = $client->putIfAbsent('lock', 'owner-1');
if ($existing === null) {
    echo "Lock acquired!";
} else {
    echo "Lock already held by: $existing";
}

Data Integrity

use CrazyGoat\TiKV\Client\RawKv\ChecksumResult;

// Compute CRC64-XOR checksum over key range
$checksum = $client->checksum('data:', 'data;');
echo "Checksum: {$checksum->checksum}";
echo "Keys: {$checksum->totalKvs}, Bytes: {$checksum->totalBytes}";

Bulk Import (SST Ingest)

// High-throughput bulk load: bypasses the Raft write path, writes
// pre-sorted SST data directly into regions. Optional TTL in seconds.
$client->ingest(['k1' => 'v1', 'k2' => 'v2'], ttl: 3600);

Cluster-wide hazard. For the duration of the call every TiKV store is switched into import mode and switched back on completion. The switch-back runs in a finally block, so exceptions are safe — but a killed process (OOM killer, deploy restart, max_execution_time) leaves the cluster in import mode, degrading it for all clients. Retryable region errors are retried per region, but the import is not atomic across regions: a permanent failure throws IngestPartialFailureException with the applied region IDs, and re-running the whole call is the recovery. Run ingest() only from a dedicated, supervised CLI process, and see Bulk Import (SST Ingest) in the operations guide for the full semantics, the configurable ingest deadline (60 s by default) and the recovery procedure for a cluster stuck in import mode.

Transactions (TxnKV)

TxnKvClient speaks TiKV's TxnKV API: snapshot reads and two-phase commit across an arbitrary set of keys, in one cluster, with no application-level locking.

Use TxnKV when a read-modify-write must be atomic across more than one key, or when several reads inside one operation must observe a single consistent snapshot. Stay on RawKV when each key is independent — RawKV has no transaction machinery to pay for, and the per-key atomic operations (compareAndSwap, putIfAbsent, which need setAtomicForCAS(true)) cover the single-key case.

Cluster mode is exclusive. TxnKV needs a cluster in default (V1) mode — enable-ttl must not be set, see tikv-v1.toml. A cluster started with enable-ttl = true runs in V1TTL mode, which serves RawKV with TTL but rejects transactional requests. One cluster serves one of the two.

Lifecycle

use CrazyGoat\TiKV\Client\TxnKv\TxnKvClient;

$txnClient = TxnKvClient::create(['127.0.0.1:2379']);   // same options as RawKvClient

$txn = $txnClient->begin([          // pessimistic by default
    'pessimistic' => true,          // false = optimistic (locks at commit)
    'priority'    => 0,             // 0 Normal (default), 1 Low, 2 High
    'lockWaitTimeMs' => 1000,       // per-txn override of the client-wide wait
]);

// ... or state the mode explicitly (recommended):
// $txn = $txnClient->beginPessimistic();
// $txn = $txnClient->beginOptimistic();

$txn->set('account:1', '100');
$txn->commit();                     // prewrite + commit; or $txn->rollback()

$txnClient->close();

A committed or rolled-back Transaction is closed and cannot be reused — a second commit() throws InvalidStateException. Start a new one instead.

TxnKvClient::create() accepts the same tls, timeout and metrics options as RawKvClient, plus retryDeadlineMs (the wall-clock bound on one transaction's internal retry loop, default Transaction::DEFAULT_RETRY_DEADLINE_MS), retryClassifier (a callable TiKvException -> RetryVerdict handed to every transaction's retry loop), gcSafePointValidation, replicaRead, tsoPoolSize and lockWaitTimeMs (the client-wide default for the pessimistic-lock wait). The per-transaction options (pessimistic, priority, enable1Pc, enableAsyncCommit, eagerPessimisticLocks, lockWaitTimeMs and requestContext) belong to TxnKvClient::begin(); lockWaitTimeMs is the one option accepted by both, where the begin() value overrides the client-wide default.

Reads and writes

$value  = $txn->get('account:1');                        // ?string
$values = $txn->batchGet(['account:1', 'account:2']);    // array<array-key, ?string>
$rows   = $txn->scan('account:', 'account;', limit: 100); // array<array{key, value}>

// Reverse scan: the first argument is the exclusive upper bound, the second
// the lower bound, so this reads [account:, account;) descending.
$latest = $txn->reverseScan('account;', 'account:', limit: 10);

$txn->set('account:1', '100');
$txn->delete('account:2');
$deleted = $txn->deleteRange('account:100', 'account:200'); // atomic tombstones

Reads are snapshot reads at startTs and observe your own buffered writes, so get() after set() returns the new value without a round trip. They do not see other transactions' uncommitted data.

Transaction::scan() differs from RawKvClient::scan(): limit: 0 means "the whole range, up to 10240 rows" — a transactional scan does not page internally. A range with more rows than that does not come back truncated: the scan throws ScanLimitExceededException instead, so a partial range is never mistaken for a complete one. Pass an explicit limit when you need one.

Transaction::reverseScan($upper, $lower, $limit) uses the same RawKvClient::reverseScan() bound convention — $upper is exclusive, so the range is [lower, upper) — and returns rows in descending key order. It walks the regions from the upper bound downwards and observes buffered writes like the forward scan; scan($start, $end, $limit, reverse: true) is the same call with a flag.

Transaction::deleteRange($start, $end) reads the range at the snapshot and buffers a delete for every key, so the deletion is atomic with the transaction. It obeys the same 10240-row scan cap and the transaction's write-set limits, so use it for bounded ranges; for an unbounded range delete in explicit batches across several transactions, or use RawKvClient::deleteRange() when a raw, non-transactional delete is acceptable.

Optimistic vs pessimistic

pessimistic defaults to true — a transaction started with begin() is a pessimistic one. That is the opposite of the TiKV documentation and of client-go and client-rust, where optimistic is the default; the difference is tracked in issue #439. The default is kept for now (changing it is a breaking change that belongs in a major release), so state the mode explicitly in new code:

// pessimistic — the physical lock is taken inside set()/delete()
$txn = $txnClient->beginPessimistic();

// optimistic — the physical lock is taken during the prewrite at commit()
$txn = $txnClient->beginOptimistic();

Both are thin wrappers over begin(): they accept the same options (priority, enable1Pc, enableAsyncCommit, eagerPessimisticLocks, lockWaitTimeMs, requestContext) and go through the same option handling. lockWaitTimeMs matters in the pessimistic mode only — an optimistic transaction takes no pessimistic lock, so it never waits for one.

Pessimistic (default) Optimistic
Lock timing physical lock during each set() / delete() lock acquired during prewrite at commit()
Where a conflict surfaces the write call, or commit() for the deferred mode commit()
Lock TTL default 30 s (pessimisticLockTtlMs), extended automatically default 3000 ms + 10 ms per write-set mutation, capped at 120000 ms (optimisticLockTtlMs)
Fits contended writes (transfers, counters) low contention (config, caching)

The value stays buffered until commit() in both modes. In default eager pessimistic mode the physical lock is taken before set() or delete() returns, so a conflict, deadlock or wait timeout raises there. A preceding pessimistic read's per-key for_update_ts is reused by the matching write, and prewrite keeps DO_CONSTRAINT_CHECK as a safety net. The legacy deferred pass is available as begin(['eagerPessimisticLocks' => false]), which moves the lock pass to commit().

lockWaitTimeMs bounds how long TiKV waits for a contended key before it answers with a "locked" error (default 1000 ms, the timeout TiKV applies by default). It can be set client-wide on TxnKvClient::create() and overridden per transaction on begin(). The value sent to TiKV is min(lockWaitTimeMs, remaining lock-wait budget, RPC deadline - 100 ms): it never exceeds the budget, shrinks as the budget is consumed, and always leaves the server room to answer before the client cancels the call. When the budget runs out the transaction fails with LockWaitTimeoutException instead of committing without the lock. See Configuration.

A pessimistic transaction can also take a lock explicitly, without writing a value:

$txn->lockKeys('account:1', 'account:2');   // lock now, no value written
$current = $txn->getForUpdate('account:1'); // lock + read in one round trip

getForUpdate() is the SELECT ... FOR UPDATE primitive: it returns the latest committed value at the lock's for_update_ts (not the snapshot value), and a missing key returns null. Both methods are pessimistic-only; an optimistic transaction throws InvalidStateException. The locks are released by commit() or rollback(): a key that was locked but never written is committed as an Op::Lock mutation, so a lock-only transaction commits normally instead of being rolled back.

The value is read from the legacy values / not_founds response fields or, on servers that use the newer wake-up mode, from the per-key results field. If a server populates neither, getForUpdate() falls back to a snapshot get(), which costs a second round trip and returns the startTs value. With trackReadSet, the consumed value (or null for a missing key) is recorded in the transaction's read set like a get() — but it is the for-update read at for_update_ts, not the startTs snapshot.

Isolation, timestamps and status

Snapshot isolation at startTs, a timestamp from PD's TSO. A transaction sees the database as of startTs plus its own writes; it never observes a partial commit.

$txn->getTxnId();       // string, unique per transaction
$txn->getStartTs();     // int, the snapshot timestamp
$txn->getCommitTs();    // ?int, null until commit() succeeds
$txn->getStatus();      // TransactionStatus::{Active, Committed, RolledBack, Undetermined}
$txn->isPessimistic();  // bool
$txn->getPriority();    // int
$txn->getWriteSet();    // array<array-key, ?string>, buffered writes
$txn->getReadSet();     // array<array-key, ?string>, resolved reads;
                        // empty unless begin(['trackReadSet' => true])

getWriteSet(), getReadSet() and batchGet() return a map, and PHP coerces a canonical decimal-integer key such as '1000', '0' or '-5' to its int array-key form — so a foreach key can be an int even though you passed a string. The lookup is lossless ($values['1000'] finds the entry stored as int 1000); a key beyond PHP_INT_MAX stays a string. Cast a foreach key with (string) before handing it to a string-typed parameter. See docs/helpers/faq.md.

Undetermined means the primary commit RPC failed at the transport level: the commit may already have been applied. Never roll back such a transaction — resolve it out of band (for example by re-checking the primary key).

Failure and retry

Retrying a transaction means starting a new one. A conflict leaves the old startTs already behind, so re-running the same object cannot succeed.

Exception What to do
TransactionConflictException discard, new transaction
DeadlockException discard, new transaction
LockWaitTimeoutException discard, new transaction
TransactionTooLargeException the rejected write was not buffered and no RPC was sent; commit the smaller write set or begin a new transaction with larger maxTxn* limits
TxnRetryableException escapes only when the internal retry budget ran out — new transaction
TxnAbortedByGcException the startTs is behind the cluster's GC safe point. New transaction; for reads that must outlive gc_life_time, call TxnKvClient::holdGcSafePoint() first
UndeterminedCommitException outcome unknown — do not roll back, resolve out of band
RegionException, GrpcException, RetryBudgetExhaustedException already retried internally; retry only if the operation is idempotent
InvalidStateException, InvalidArgumentException programming or lifecycle error — never retry

The full table, with accessors and the reasoning per row, is in Error Handling.

Long-running transactions

A transaction that stays open between operations keeps its locks only for the granted TTL. A pessimistic transaction extends its primary lock automatically: every get(), batchGet(), scan(), set() and delete() sends a TxnHeartBeat when less than half of the lock TTL remains. An optimistic transaction holds no lock before commit(), so it sends none. A transaction that waits without touching TiKV still needs an explicit Transaction::heartbeat() before the previously granted TTL elapses (10 s is a safe default); it returns the TTL TiKV actually granted.

$txn = $txnClient->begin();
$txn->set('account:1', '100');

// ... a long computation or an external call ...

$grantedTtlMs = $txn->heartbeat(10000);

$txn->set('account:2', '0');
$txn->commit();

Abandoned transactions

Transaction::__destruct() rolls back a transaction that is still Active. That is a safety net, not a mechanism to rely on: destruction order at shutdown is not guaranteed, the rollback is a network RPC, and a failure is only logged. Always commit() or rollback() in your own code.

Complete example

examples/txn.php defines this retry helper — run the script verbatim against a TxnKV cluster:

docker compose -f docker-compose.yml -f docker-compose.txnkv.yml down -v
docker compose -f docker-compose.yml -f docker-compose.txnkv.yml up -d pd tikv1
php examples/txn.php
use CrazyGoat\TiKV\Client\TxnKv\Exception\DeadlockException;
use CrazyGoat\TiKV\Client\TxnKv\Exception\LockWaitTimeoutException;
use CrazyGoat\TiKV\Client\TxnKv\Exception\TransactionConflictException;
use CrazyGoat\TiKV\Client\TxnKv\Exception\TxnRetryableException;
use CrazyGoat\TiKV\Client\TxnKv\TransactionStatus;
use CrazyGoat\TiKV\Client\TxnKv\TxnKvClient;

/**
 * Move $amount from one account to another atomically, retrying on conflict.
 *
 * The retry has to build a NEW transaction: the failed one holds a
 * startTs that has already lost the race, so re-running the same object can
 * never succeed.
 */
$transfer = static function (TxnKvClient $client, string $from, string $to, int $amount): void {
    for ($attempt = 1;; $attempt++) {
        $txn = $client->begin(); // pessimistic by default
        try {
            $fromBalance = (int) $txn->get($from);
            $toBalance = (int) $txn->get($to);

            if ($fromBalance < $amount) {
                throw new RuntimeException("Insufficient funds in {$from}");
            }

            $txn->set($from, (string) ($fromBalance - $amount));
            $txn->set($to, (string) ($toBalance + $amount));
            $txn->commit();

            return;
        } catch (
            TransactionConflictException
            | DeadlockException
            | LockWaitTimeoutException
            | TxnRetryableException $e
        ) {
            if ($txn->getStatus() === TransactionStatus::Active) {
                $txn->rollback();
            }
            if ($attempt >= 5) {
                throw $e;
            }
            echo "  Conflict on attempt {$attempt} ({$e->getMessage()}), retrying...\n";
            usleep(50_000 * $attempt);
        } catch (Throwable $e) {
            if ($txn->getStatus() === TransactionStatus::Active) {
                $txn->rollback();
            }
            throw $e;
        }
    }
};

The helper is a closure so the script declares no global function next to the autoloader require. The getStatus() === Active guard matters: after an undetermined commit the transaction is closed, and calling rollback() on it would throw a second exception over the first.

TLS/SSL Configuration

use CrazyGoat\TiKV\Client\RawKv\RawKvClient;
use CrazyGoat\TiKV\Client\Tls\TlsConfigBuilder;

// Configure TLS with CA certificate only (server verification)
$options = [
    'tls' => [
        'caCertFile' => '/path/to/ca.crt',
        'caCertBaseDir' => '/path/to', // optional: restrict to base directory
    ],
];

// Or with mutual TLS (mTLS) - client certificate authentication
$options = [
    'tls' => [
        'caCertFile' => '/path/to/ca.crt',
        // Optional base-directory restrictions (*BaseDir apply only to their
        // matching file reads; caCertBaseDir → caCertFile, clientCertBaseDir →
        // clientCertFile + clientKeyFile)
        'caCertBaseDir' => '/path/to',
        'clientCertFile' => '/path/to/client.crt',
        'clientKeyFile' => '/path/to/client.key',
        'clientCertBaseDir' => '/path/to',
    ],
];

// For inline PEM content, use the *Pem variants:
$options = [
    'tls' => [
        'caCertPem' => $caPemString,
        'clientCertPem' => $clientCertPemString,
        'clientKeyPem' => $clientKeyPemString,
    ],
];

$client = RawKvClient::create(['tikv.example.com:2379'], options: $options);

The *BaseDir options are optional and restrict where the certificate files may live: the path is canonicalized with realpath() before the comparison, so a .. segment or a symlink cannot pull in a file from outside the base directory, and the comparison uses directory boundaries rather than a string prefix, so a sibling like /etc/tikv-attacker cannot pass a base of /etc/tikv. caCertBaseDir guards caCertFile; clientCertBaseDir guards clientCertFile and clientKeyFile.

Deprecated: the legacy caCert, clientCert and clientKey keys guess between a file path and inline PEM content and cannot be restricted to a base directory. They still work for backward compatibility — use the *File / *Pem keys above instead.

To make a misconfigured TLS setup fail instead of silently downgrading to plaintext, set allowInsecure to false — the client then throws InvalidStateException when it cannot open a TLS channel:

$options = [
    'tls' => ['caCertFile' => '/path/to/ca.crt'],
    'allowInsecure' => false, // recommended for production
];

$client = RawKvClient::create(['tikv.example.com:2379'], options: $options);

Connection Lifetime and Transport Ownership

The gRPC transport behind a client is long-lived and shared: it keeps one open channel per address, and that cache only pays off when the same transport serves many requests. Create the client once (at the start of a worker, or in a container) and reuse it — a client per request throws the region cache and the PHP-side channel handles away and starts cold, though ext-grpc may still reuse the underlying connection (see Connection lifecycle and process model).

Closing is the mirror image of creating:

  • a client created by RawKvClient::create() / TxnKvClient::create() owns the transport it built and closes it in close();
  • a transport you build and pass in (options['grpcClient']) belongs to your application — the client never closes it, so the same transport can serve a RawKV and a transactional client. Close it yourself;
  • a client you assemble by hand (new RawKvClient($pdClient, $grpc)) follows the same rule through its ownership flags: RawKvClient defaults ownsGrpcClient / ownsPdClient to false, so close() and the destructor leave both given connections open and you can share them with other clients — pass ownsGrpcClient: true / ownsPdClient: true to transfer ownership. TxnKvClient still defaults ownsGrpcClient to true and always closes its PD client; pass ownsGrpcClient: false there. #990 tracks aligning it with RawKvClient. Either way, the client still clears its own region cache.
use CrazyGoat\TiKV\Client\Grpc\GrpcClient;

// The transport the application builds and owns.
// $tlsConfig is your TlsConfig (null = plaintext); $logger your PSR-3 logger.
$grpc = new GrpcClient($logger, $tlsConfig);
$options = ['grpcClient' => $grpc];

$raw = RawKvClient::create(['127.0.0.1:2379'], $logger, $options);
$txn = TxnKvClient::create(['127.0.0.1:2379'], $logger, $options);

// …use both clients…

// The application owns $grpc:
$grpc->close();

options['metrics'] stays a client-side option: it backs the client-side counters (region cache, retries) of every client, injected transport or not. The transport's own RPC counters follow the transport — pass the same backend to it (new GrpcClient(metrics: $metrics)) to get them everywhere.

What closing a transport really does

ext-grpc keeps a process-global table of persistent channels, keyed by the target, a hash of the channel arguments and a hash of the credentials. Two Grpc\Channel objects built from the same key therefore share one underlying grpc_channel — as long as the key is in that table. The table is bounded per target (one persisted channel per target by default), so a channel created beyond that bound is private to its handle: ext-grpc logs "The number of channel for the target is maxed out bounded" and does not persist it. A private channel is never shared, so it is never at risk either.

Channel::close() is a reference-count decrement (php_grpc_channel_unref()), not a destroy: the underlying channel survives until its last holder releases it, and the persistent table is one of those holders. So closing one client never invalidates a channel another client holds — it only drops that client's own reference to it. Verified against the deployed extension (grpc 1.83.0; the persistent-channel code is unchanged in the oldest supported release, 1.57).

The connection itself lives one layer below: gRPC core gives each ClientChannel a subchannel from either a local pool or its process-global subchannel pool (the default, grpc.use_local_subchannel_pool false), and the global pool shares subchannels — and therefore TCP connections — among channels (client_channel.cc, GetSubchannelPool()). A new grpc_channel that keeps that default can still reuse the old connection.

Two consequences for the API:

  • close() releases this client's handles; the TCP connection stays warm in the process (until the process ends or the per-target persist bound drops the entry). It does not tear the connection down.
  • closeChannel() drops the cached handle and forces a new connection: the replacement channel is built with 'force_new' => true and 'grpc.use_local_subchannel_pool' => 1. The first keeps it out of ext-grpc's persistent table; the second keeps its subchannel out of gRPC core's global subchannel pool, which would otherwise hand the new channel the existing subchannel — and with it the TCP connection that just failed (issue #717). The retry path is its caller — it closes the channel after a transport error. The idle and the LRU evictions are different: they keep both pools, so a later call reuses the warm connection.

PSR-3 Logging

Monolog is optional. The logging snippet below uses Monolog for illustration, but it is a dev-only dependency of this library. Install it in your own project with composer require monolog/monolog, or pass any PSR-3 LoggerInterface implementation instead — the client itself only depends on psr/log.

use CrazyGoat\TiKV\Client\RawKv\RawKvClient;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;

// Create a PSR-3 compatible logger
$logger = new Logger('tikv');
$logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG));

// Pass logger to client
$client = RawKvClient::create(['127.0.0.1:2379'], logger: $logger);

// The client will log:
// - Connection attempts and failures
// - Retry attempts with backoff information
// - Region cache hits/misses (DEBUG level only)
// - Error conditions

Every key the client itself formats for a log line or an exception message is redacted before it leaves the client. The default KeyRedactor replaces each key with a truncated keyed hash and the total byte length, for example #3c469e9d6c5875d3 (5 bytes). The digest is the first 16 hex characters of hash_hmac('sha256', $key, $salt), so it is one-way for keys of every length — a key of 8 bytes or fewer is no longer hex-encoded in full — while the same key keeps the same token within a process, so log lines can still be correlated. The salt is 32 random bytes generated on first use, so the example shows the shape only and the exact token differs per process; pin a high-entropy salt (at least 16 bytes, 32 recommended; KeyRedactor::generateSalt() returns one) with KeyRedactor::setSalt() before the first redaction when tokens must be comparable across processes or restarts. KeyRedactor::setRedactor() replaces the default entirely: it is an explicit opt-out, and a reversible scheme installed there re-exposes the key material. This guarantee covers keys the client holds; TiKV's own error text can still name a key the client never saw, and the client cannot redact what it never received. See Log Redaction for the format, the server-text caveat, the override trade-off and the exception messages that carry a token.

Complete Example

<?php
require 'vendor/autoload.php';

use CrazyGoat\TiKV\Client\RawKv\RawKvClient;
use Psr\Log\NullLogger;

// Setup logging. Logging is optional and any PSR-3 logger works; the no-op
// logger keeps this example runnable with the published package alone.
// Monolog is only a dev dependency: composer require monolog/monolog
$logger = new NullLogger();

// Connect to TiKV
$pdEndpoints = ['127.0.0.1:2379'];
$client = RawKvClient::create($pdEndpoints, logger: $logger);

try {
    // Store user data with TTL
    $client->put('user:123', json_encode(['name' => 'Alice', 'age' => 30]), ttl: 3600);
    $client->put('user:456', json_encode(['name' => 'Bob', 'age' => 25]), ttl: 3600);
    
    // Batch retrieve
    $users = $client->batchGet(['user:123', 'user:456']);
    foreach ($users as $key => $value) {
        if ($value !== null) {
            $data = json_decode($value, true);
            echo "$key: {$data['name']}\n";
        }
    }
    
    // Scan all users with the lazy iterator (constant memory). A plain
    // scanPrefix('user:') pages internally at MAX_SCAN_LIMIT (10240) rows per
    // RPC but buffers the whole result, and throws ScanLimitExceededException
    // once it exceeds options['maxScanRows'] (default 100000). The iterator
    // buffers only one page and has no total cap.
    $userCount = 0;
    foreach ($client->scanPrefixIterator('user:') as $_) {
        $userCount++;
    }
    echo "Total users: $userCount\n";
    
    // Check TTL. getKeyTTLResult() tells a missing key apart from a key
    // without an expiry; getKeyTTL() returns null for both.
    $ttl = $client->getKeyTTLResult('user:123');
    if (!$ttl->exists) {
        echo "TTL: key not found\n";
    } elseif ($ttl->ttlSeconds === null) {
        echo "TTL: no expiry\n";
    } else {
        echo "TTL remaining: {$ttl->ttlSeconds} seconds\n";
    }
    
    // Enable atomic mode for CAS operations
    $client->setAtomicForCAS(true);
    
    // Atomic counter update
    $client->put('counter', '0');
    $result = $client->compareAndSwap('counter', '0', '1');
    if ($result->swapped) {
        echo "Counter incremented!\n";
    }
    
} finally {
    $client->close();
}

Implemented Operations

Every user-facing capability of the library, taken from src/Client/ — the RawKvClient, the TxnKvClient (including its explicit beginOptimistic() / beginPessimistic() entry points), the Transaction and the client options. Lower-level utilities such as KeyRedactor, TimeoutConfig, SlowLogConfig, TlsConfigBuilder, StoreCacheInterface and GrpcStatusCode are not operations; the Internal section explains which of them are extension points and that the rest may change in any release. The list is cross-checked against CHANGELOG.md at release time, see docs/release-workflow.md.

Core CRUD

  • ✅ Get / Put / Delete — Single key operations
  • ✅ BatchGet / BatchPut / BatchDelete — Batch operations with parallel execution

Scanning

  • ✅ Scan — Range scan [startKey, endKey) with limit and keyOnly options
  • ✅ ReverseScan — Reverse range scan (native reverse=true)
  • ✅ ScanPrefix — Prefix-based scanning
  • ✅ ScanIterator / ScanPrefixIterator — Lazy auto-paginating scan iterators (ScanIterator), constant memory for large ranges; they throw ClientClosedException after the owning client is closed
  • ✅ BatchScan — Multiple non-contiguous range scanning

Range Operations

  • ✅ DeleteRange — Delete all keys in [startKey, endKey)
  • ✅ DeletePrefix — Delete all keys with a given prefix

TTL

  • ✅ PutWithTTL — Store with expiration (seconds)
  • ✅ GetKeyTTL — Get remaining TTL of a key

Atomic Operations

  • ✅ CompareAndSwap — Atomic CAS with CasResult (swapped + previousValue)
  • ✅ PutIfAbsent — Conditional insert (returns existing value or null)
  • ✅ Atomic mode — setAtomicForCAS(true) / isAtomicForCAS() switch the write path to TiKV's atomic code path

Data Integrity

  • ✅ Checksum — CRC64-XOR checksum over key range with ChecksumResult

Bulk Import

  • ✅ Ingest — SST bulk import: the key/value pairs are sorted and written straight into the regions, bypassing the Raft write path (optional TTL); the payload is streamed in chunks, retryable region errors are retried per region, IngestPartialFailureException reports a partial import, and options['maxIngestSize'] optionally bounds the payload before any RPC

Transaction Capabilities

  • ✅ TxnKvClient / Transaction — ACID transactions with snapshot reads and two-phase commit
  • ✅ Pessimistic & Optimistic — pessimistic locks by default; the primary lock of a pessimistic transaction is kept alive automatically, and heartbeat() remains for a transaction that waits without touching TiKV
  • ✅ Transaction size limits and request batching — maxTxnEntryCount, maxTxnEntrySize and maxTxnTotalSize bound the write set and throw TransactionTooLargeException before any RPC; trackReadSet (default false) opts in to the read set populated by get(), batchGet(), getForUpdate() and scan(), and maxReadSetEntries / maxReadSetTotalSize bound it; getWriteSetSize() / getWriteSetCount() and getReadSetSize() / getReadSetCount() expose their sizes; maxTxnBatchSize / maxTxnBatchCount split prewrite, pessimistic-lock, commit and rollback requests per region
  • ✅ Automatic primary-lock keepalive — TxnHeartBeat is sent automatically when less than half of the lock TTL remains; optimisticLockTtlMs / pessimisticLockTtlMs set the lock TTLs
  • ✅ Batch lock resolution and resolved-status cache — one KvResolveLock per (region, transaction), with a bounded ResolvedTxnStatusCache of final transaction outcomes, and a per-transaction ResolvedLockTracker that classifies each resolution against the snapshot start ts and sends the disjoint Context.resolved_locks / Context.committed_locks sets
  • ✅ Conflict retry — typed TransactionConflictException, DeadlockException, LockWaitTimeoutException
  • ✅ GC safe points — holdGcSafePoint() / releaseGcSafePoint() for reads that outlive gc_life_time
  • ✅ Replica reads — options['replicaRead'] picks the replica that serves a read — ReplicaReadMode::Leader, ::Follower, ::Mixed or ::PreferLeader — on both clients
  • ✅ Resource control — options['resourceGroup'] and options['resourceGroupTag'] (and the matching begin() options) attribute every request to a TiKV resource group through Context.resource_control_context and Context.resource_group_tag
  • ✅ One-phase and async commit — the begin() options enable1Pc and enableAsyncCommit
  • ✅ Transaction state — getTxnId(), getStartTs(), getCommitTs(), getStatus(), isPessimistic(), getPriority(), getWriteSet(), getWriteSetSize(), getWriteSetCount(), getReadSet(), getReadSetSize(), getReadSetCount(), clearReadSet()

Observability

  • ✅ Metrics — options['metrics'] injects a MetricsInterface backend (InMemoryMetrics ships with the library for tests and benchmarks); getMetrics() returns the backend in use
  • ✅ Prometheus exporter — PrometheusMetrics is a dependency-free backend that renders the Prometheus text exposition format; no extra Composer package is required
  • ✅ Extended metrics — DetailedMetricsInterface (optional, so existing backends keep working) records retry backoff durations, per-store attempt outcomes, lock-resolution counts and two-phase-commit phase timings
  • ✅ Tracing hooks — options['tracer'] accepts a TraceEventInterface callback; the client emits structured events for gRPC calls, 2PC phases and lock resolution, ready to bridge to OpenTelemetry (see Tracing)
  • ✅ Health check — healthCheck() probes PD with a lightweight GetMembers RPC and returns the cluster ID (with options['expectedClusterId'] set, a response without a cluster-ID header is an error)
  • ✅ Cluster ID — getClusterId() returns the cluster ID learned from PD
  • ✅ Slow log — options['slowLog'] logs the RawKV operations that are slower than their per-operation threshold
  • ✅ PSR-3 Logging — Structured logging with any PSR-3 logger
  • ✅ Idle channel reaping — reapIdleChannels() on both clients drops cached gRPC channels idle longer than idleTtlMs, for a worker to call from its own tick
  • ✅ Region cache refresh — refreshRegionCache() on both clients re-queries PD for the cached regions at most once per regionCacheRefreshMs, for the same worker tick; preloadRegions() warms the cache at boot

API Versions and Codec

  • ✅ API V1 and V2 — options['apiVersion'] and options['keyspace']; V2 prefixes the keys with the mode and the keyspace ID
  • ✅ Memory-comparable encoding — MemComparableCodec encodes the PD region boundaries (TiDB-compatible EncodeBytes)

Configuration Options

Everything in the table is passed in the options array of RawKvClient::create() / TxnKvClient::create(). The per-transaction options of TxnKvClient::begin() are listed below the table. The full reference, with the defaults, is in Configuration.

Option What it does
tls Server or mutual TLS, from file paths or inline PEM
allowInsecure false refuses to open a channel without usable TLS credentials
timeout A gRPC deadline per operation class — read, write, batch, scan, delete-range, checksum, ingest, PD, TSO, lock resolution
slowLog Thresholds above which an operation is logged as slow
metrics A MetricsInterface backend for the RPC, retry and region-cache counters
tracer A TraceEventInterface callback for structured trace events (gRPC calls, 2PC phases, lock resolution)
retryDeadlineMs Wall-clock bound on one retry loop
retryClassifier A callable TiKvException -> RetryVerdict consulted before the built-in classifier on every retry loop (Custom Retry)
maxConcurrency Maximum number of in-flight requests in the batch and range fan-outs
maxIngestSize Payload cap (keys plus values) for one ingest() call; 0 (default) = unlimited, exceeding it throws IngestTooLargeException
maxScanRows Row cap for an unbounded (limit: 0) scan; exceeding it throws ScanLimitExceededException
replicaRead A ReplicaReadPolicy object — ReplicaReadMode::Leader, ::Follower, ::Mixed or ::PreferLeader (Replica Reads)
requestContext A RequestContextOptions object — isolation level, not-fill-cache, max execution duration, request source, resource group tag/name and busy threshold applied to every request (Request Context Controls)
batchCommands RawKV experimental RPC multiplexing over the BatchCommands stream
resourceGroup / resourceGroupTag Resource-control attribution — the resource group and opaque tag sent on every request's Context (Resource Control)
apiVersion / keyspace API V2 with a named keyspace
grpc gRPC channel arguments — message sizes, keepalive, compression and the HTTP/2 window size
maxChannels / idleTtlMs The channel pool — how many gRPC channels the transport keeps open and after how long an unused one is evicted (Channel Pool)
grpcClient A transport the application built — the client uses it as it is and never closes it (Sharing One Transport)
tsoPoolSize / lowResTimestampMaxStalenessMs TSO batching and the low-resolution timestamp cache of the TxnKV timestamp oracle
expectedClusterId The PD cluster ID every PD/TSO response must report when set — a mismatch or a missing cluster-ID header throws instead of adopting the server's value (Cluster Identity Verification)
allowedStoreHosts / storeHostPolicy / allowedStorePorts Which store addresses the client is allowed to talk to
gcSafePointValidation / gcSafePointRefreshMs TxnKV GC safe-point validation and its refresh interval
lockWaitTimeMs TxnKV client-wide pessimistic-lock wait timeout; begin() overrides it per transaction (Pessimistic Lock Wait Timeout)
regionCacheRefreshMs Minimum interval between non-forced refreshRegionCache() ticks; 0 (default) disables the non-forced refresh, which is opt-in (Region Cache Refresh and Preload)

The transport options (tls, allowInsecure, maxChannels, idleTtlMs, grpc) cannot be combined with grpcClient — they configure a transport the client does not build, so they are rejected instead of being ignored.

TxnKvClient::begin() takes its own options per transaction: pessimistic (the default is true), priority (0 Normal, 1 Low, 2 High), enable1Pc (one-phase commit), enableAsyncCommit, eagerPessimisticLocks (pessimistic locking at write time — the default — or at commit time), lockWaitTimeMs (how long TiKV may wait for a contended lock; overrides the client-wide create() value), optimisticLockTtlMs (optimistic lock TTL baseline, default 3000 ms), pessimisticLockTtlMs (pessimistic lock TTL, default 30000 ms), maxTxnEntryCount / maxTxnEntrySize / maxTxnTotalSize (write-set bounds, each 0 = unlimited), trackReadSet (read-set tracking, default false), maxReadSetEntries / maxReadSetTotalSize (read-set bounds, each 0 = unlimited), maxTxnBatchSize (bytes per prewrite/pessimistic-lock/commit/rollback request per region, default 16384) and maxTxnBatchCount (mutations/keys per prewrite/pessimistic-lock/commit/rollback request per region, default 512). requestContext is a RequestContextOptions object that replaces the client-level options['requestContext'] for this transaction only; pass null to disable the client-level controls.

Two settings are methods on the client rather than options: setAtomicForCAS() / isAtomicForCAS() (see Atomic Operations) and setColumnFamily() / getColumnFamily(), which selects the column family of the RawKV requests.

Error Handling

  • ✅ Typed exceptions — a dedicated exception class per failure mode; all of them but InvalidArgumentException (which extends PHP's \InvalidArgumentException) extend TiKvException; the most common exceptions and their retryability verdict are listed in Error Handling

Infrastructure

  • ✅ PD Region Discovery — With RegionEpoch support
  • ✅ Region Routing — Direct to correct TiKV node
  • ✅ Region Cache — In-memory caching of region metadata; a custom RegionCacheInterface can be injected through the constructor. RawKV forward scans/iterators and TxnKV Transaction::scan() read the cache first and query PD only for the uncovered ranges, reporting regionCacheHit() / regionCacheMiss() under the scan / txn_scan labels; RawKV reverseScan() and TxnKV Transaction::reverseScan() resolve regions top-down through the region resolver and report region_resolution
  • ✅ Region cache refresh and preload — opt-in, PHP-process-model friendly (no timers): call refreshRegionCache() from a worker tick (throttled by options['regionCacheRefreshMs'], default 0 = disabled) and preloadRegions([['start', 'end'], ...]) at boot to warm the keys the worker will touch without serving a read. Both keep the old entry and only log when PD fails, and are no-ops after close() (Region Cache Refresh and Preload)
  • ✅ Store Cache — In-memory caching of store addresses
  • ✅ Retry Logic — Automatic retry with exponential backoff; the wall-clock bound of one retry loop is options['retryDeadlineMs'] and options['retryClassifier'] extends or overrides the built-in error classification per client (RetryVerdict::pass() keeps it) (see Retry System and Custom Retry)
  • ✅ NotLeader Handling — Automatic leader redirection
  • ✅ Batch Async Execution — Parallel execution across regions
  • ✅ TLS/SSL Support — Server and mutual TLS authentication
  • ✅ Lifecycle — close() clears the region cache and closes the PD connection; the gRPC transport is closed with it only when the client owns it — a transport passed in through options['grpcClient'] belongs to the application and is never closed by the client (see Connection Lifetime and Transport Ownership)

Project Structure

The tree below is verified against the filesystem by tests/Unit/Docs/ReadmeProjectStructureTest.php: every directory under src/Client/, every test suite and every example script has to appear in it, and every file, directory or class it names has to exist. A new package therefore fails the unit suite until the tree names it.

The tree is a map, not a file inventory. It names the test suites and not the directories below tests/Unit/, and it names the generated protobuf packages the library uses most and not all of them — the rest come with the proto submodule and change with it, so they are not checked for completeness.

src/
├── Client/
│   ├── Batch/                         # Concurrent fan-out across regions
│   │   └── BatchCommands/             # BatchCommands multiplexing over a bidi stream
│   ├── Cache/                         # Region and store metadata caches
│   ├── Codec/                         # Key encoding (CodecV1, CodecV2, MemComparableCodec)
│   ├── Connection/                    # PD client, keyspace resolver, TSO oracle
│   ├── Exception/                     # Exception hierarchy (TiKvException, GrpcException, RegionException)
│   ├── Grpc/                          # Low-level gRPC transport, deadlines, timeouts
│   ├── Observability/                 # Metrics and tracing (MetricsInterface, PrometheusMetrics, TraceEventInterface)
│   ├── RawKv/                         # RawKV API (RawKvClient, RawKvCrud, RawKvBatch, RawKvScanner)
│   │   └── Dto/                       # RawKV DTOs (RegionInfoMapper)
│   ├── Region/                        # Region resolution, grouping, range clipping
│   │   └── Dto/                       # Region DTOs (RegionInfo, PeerInfo, ReplicaReadTarget)
│   ├── Retry/                         # Retry executor (RetryExecutor, Backoff, ErrorClassifier)
│   ├── Tls/                           # TLS configuration
│   ├── TxnKv/                         # Transactional API (TxnKvClient, Transaction, TwoPhaseCommitter)
│   │   └── Exception/                 # Transaction exceptions (TransactionConflictException, DeadlockException)
│   └── Util/                          # Key ordering and redaction (KeyOrder, KeyRedactor)
└── Proto/                             # Generated protobuf classes (Kvrpcpb, Pdpb, Tikvpb, …)
    ├── Kvrpcpb/                       # TiKV request/response
    ├── Pdpb/                          # PD request/response
    └── Tikvpb/                        # gRPC service stubs

tests/
├── Unit/                              # Unit tests
├── Integration/                       # Integration tests
├── E2E/                               # End-to-end tests
└── phpstan/                           # PHPStan bootstrap (protobuf class alias)

examples/
├── basic.php                          # Basic CRUD example
├── batch.php                          # Batch operations example
├── scan.php                           # Scanning examples
├── txn.php                            # Transactions with conflict retry
├── ttl.php                            # TTL operations example
├── atomic.php                         # Atomic operations example
├── tls.php                            # TLS configuration example
└── logging.php                        # PSR-3 logging example

Available Commands (Makefile)

make install          # Install PHP dependencies
make test             # Run all tests (unit + e2e)
make test-unit        # Run unit tests only
make test-e2e         # Run E2E tests with TiKV cluster
make proto-generate   # Generate PHP classes from proto files
make proto-clean      # Remove generated proto classes
make build            # Build Docker images
make up               # Start TiKV cluster
make down             # Stop TiKV cluster
make logs             # Show TiKV cluster logs
make clean            # Clean everything (containers + volumes)
make example          # Run basic example
make shell            # Open development shell

The Compose containers run as an unprivileged user, so the image is built with the host owner's UID/GID (HOST_UID/HOST_GID) to keep the bind-mounted checkout writable. make and the E2E scripts detect both automatically; set them explicitly only for a direct docker compose call, then rebuild.

Examples

See the examples/ directory for complete working examples:

  • basic.php — Basic CRUD operations
  • batch.php — Batch operations with parallel execution
  • scan.php — Range scanning and prefix scanning
  • txn.php — Transactions: transfer, snapshot reads, rollback, conflict retry
  • ttl.php — Time-to-live operations
  • atomic.php — Compare-and-swap and put-if-absent
  • tls.php — TLS/SSL configuration
  • logging.php — PSR-3 logging integration

Run any example:

make up  # Start TiKV cluster first
php examples/basic.php

examples/logging.php uses Monolog for illustration, and Monolog is a dev-only dependency of this library. Install it with composer require monolog/monolog, or adapt the example to any PSR-3 LoggerInterface; the other examples run with the published package alone.

examples/txn.php needs a cluster in default (V1) mode, so use docker-compose.txnkv.yml and start from fresh volumes — TiKV refuses to disable TTL on a cluster that was bootstrapped with it.

Configuration

Client Options

$options = [
    'tls' => [
        'caCertFile' => '/path/to/ca.crt',      // Server CA certificate (file path)
        'clientCertFile' => '/path/to/client.crt', // Client certificate (for mTLS)
        'clientKeyFile' => '/path/to/client.key',  // Client private key (for mTLS)
        // Or inline PEM: caCertPem, clientCertPem, clientKeyPem
    ],
    'allowInsecure' => false,     // Fail closed: refuse a plaintext channel
    'maxChannels' => 64,          // Open gRPC channels kept at the same time
    'idleTtlMs' => 600000,        // Idle time before an unused channel is evicted

    // Slow-operation logging (opt-in): a PSR-3 warning is logged when an
    // operation takes longer than its threshold (milliseconds). Without this
    // key, slow logging is off entirely; a threshold of 0 (or a negative one)
    // disables that one operation type.
    // See docs/configuration.md#slow-operation-logging.
    'slowLog' => [
        'readThresholdMs'        => 100,  // default 100  (get, getKeyTTL, getKeyTTLResult)
        'writeThresholdMs'       => 200,  // default 200  (put, delete, CAS)
        'batchReadThresholdMs'   => 500,  // default 500  (batchGet)
        'batchWriteThresholdMs'  => 500,  // default 500  (batchPut, batchDelete)
        'scanThresholdMs'        => 1000, // default 1000 (scan, batchScan)
        'deleteRangeThresholdMs' => 2000, // default 2000 (deleteRange)
        'checksumThresholdMs'    => 3000, // default 3000 (checksum)
    ],];

$client = RawKvClient::create(
    pdEndpoints: ['127.0.0.1:2379'],
    logger: $logger,           // PSR-3 logger (optional)
    options: $options          // Additional options (optional)
);

The full list of options lives in docs/configuration.md.

The slowLog option logs a warning with the message Slow TiKV operation and the operation type, the redacted key, the duration and the threshold — see Slow Operation Logging for the full reference.

TiKV Configuration

For TTL support, enable it in tikv.toml:

[storage]
enable-ttl = true

Note: TTL mode is exclusive with TxnKV — see TTL (Time-To-Live).

Roadmap

Completed

Shipped in the released versions — CHANGELOG.md has the details of each one.

  • ✅ RawKV core — CRUD, batch operations, scans, range operations, TTL and compare-and-swap
  • ✅ Transactions (TxnKV) — snapshot reads, two-phase commit, pessimistic and optimistic locking, GC safe points
  • ✅ Transactional reverse scan and range deletion — Transaction::reverseScan() / scan(..., reverse: true) walk regions top-down and observe buffered writes; deleteRange() buffers atomic tombstones for a bounded range (#435)
  • ✅ API V2 and keyspaces — V2 key encoding and PD keyspace resolution
  • ✅ Multi-endpoint PD — leader discovery and failover
  • ✅ GC safe-point awareness — service safe points for long-running reads
  • ✅ Replica reads — stale reads, follower reads and the replica read modes
  • ✅ Fast commit modes — one-phase commit and async commit
  • ✅ TSO batching — batched TSO requests and a low-resolution timestamp cache
  • ✅ Memory-comparable region encoding — the TiDB-compatible EncodeBytes scheme for TxnKV region lookups
  • ✅ Experimental RPC multiplexing — the BatchCommands bidirectional stream (opt-in)
  • ✅ TLS/SSL Support
  • ✅ PSR-3 Logging
  • ✅ Region & Store Caching
  • ✅ Connection Pooling
  • ✅ Batch Async Execution
  • ✅ Retry with Exponential Backoff
  • ✅ Per-key TTL in BatchPut
  • ✅ Scan limit enforcement — RawKV scans page at MAX 10240 rows per RPC; limit: 0 returns the whole range or throws ScanLimitExceededException past options['maxScanRows']; the transactional scan has its own 10240-row cap and throws ScanLimitExceededException for a larger range instead of truncating; a negative limit throws InvalidArgumentException before any RPC
  • ✅ Batch auto-splitting by size/count
  • ✅ Batch lock resolution and resolved-status cache — one KvResolveLock per (region, transaction), with a bounded ResolvedTxnStatusCache and a bounded per-transaction ResolvedLockTracker that feeds the disjoint Context.resolved_locks / Context.committed_locks sets
  • ✅ Automatic primary-lock keepalive — TxnHeartBeat when less than half of the lock TTL remains, with optimisticLockTtlMs / pessimisticLockTtlMs
  • ✅ Transaction size limits and request batching — write-set limits with TransactionTooLargeException, getWriteSetSize() / getWriteSetCount(), and prewrite/pessimistic-lock/commit/rollback splitting by maxTxnBatchSize / maxTxnBatchCount
  • ✅ Read-set limits and streaming SST ingest — opt-in trackReadSet with maxReadSetEntries / maxReadSetTotalSize, maxIngestSize, and a generator-based RawWrite stream that no longer triple-buffers the payload
  • ✅ Region-cache refresh, preload and cache-first scans — refreshRegionCache(), preloadRegions() and regionCacheRefreshMs, with scan paths that serve cached regions and query PD only for the gaps
  • ✅ The remaining kvrpcpb.Context control fields — RequestContextOptions (isolation level, not-fill-cache, max execution duration, request source, resource group tag/name, busy threshold)
  • ✅ Resource control and resource groups — resourceGroup / resourceGroupTag attribution on every request, with per-transaction overrides
  • ✅ Typed CAS outcome — CasIndeterminateException for a compare-and-swap whose outcome cannot be determined, never retried
  • ✅ Cluster identity verification — options['expectedClusterId'] pins the PD cluster ID on the PD and TSO paths
  • ✅ Store-cache invalidation and operation-scoped retry budget — the cached store is dropped on StoreNotMatch and transport failures, and one retry budget covers a whole logical operation
  • ✅ Configurable gRPC channel parameters — message sizes, keepalive, keepalivePermitWithoutCalls, compression and the HTTP/2 initial window size (#423)
  • ✅ Store health tracking and failover — a per-store circuit breaker with cooldown, half-open probing, StoreUnavailableException and replica-read exclusion (#424)
  • ✅ PD connection reuse — TxnKvClient::pdClient() exposes the client's own PdClientInterface for cluster inspection and tests (#515)
  • ✅ Explicit pessimistic key locking — Transaction::lockKeys() and getForUpdate() acquire locks without writing, with the same conflict, deadlock and wait-timeout handling (#436)
  • ✅ Peer-role awareness — PeerInfo carries the TiKV role and a learner or witness can never be adopted as the leader (#258)
  • ✅ Prometheus exporter and tracing hooks — a dependency-free PrometheusMetrics backend, the optional DetailedMetricsInterface metric set (backoff, per-store outcomes, lock resolution, 2PC timings) and a TraceEventInterface callback for bridging to OpenTelemetry (#434)
  • ✅ RawBatchScan for multi-range scans — batchScan() groups the ranges by region and sends one RawBatchScan per region, with the per-range result shape unchanged (#433)
  • ✅ Transactional reverse scan and range deletion — Transaction::reverseScan() walks regions top-down with read-your-writes, and deleteRange() deletes a range inside the transaction (#435)
  • ✅ Read-through locks for snapshot reads — locks resolved by a transaction are sent as resolved_locks/committed_locks on later requests, split by the snapshot's start_ts (#432)

Planned

Not shipped yet. Every item is an open issue in the repository, and the issue is the source of truth — a feature moves to Completed when its issue is closed. The list holds the open feature-gap issues whose work is still to be done; the other open issues of the repository (API additions, quality fixes) are tracked in their own issues and are not part of the roadmap. A feature-gap issue whose work already shipped stays out of the list even while the issue is still open.

Every roadmap feature gap has shipped as of v0.14.0; the list is empty.

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Write tests for your changes
  4. Ensure all tests pass: make test
  5. Submit a pull request

License

MIT

About

PHP client for TiKV RawKV API via gRPC — CRUD, batch ops, scanning, TTL, atomic operations, TLS, region caching

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages