Skip to content

Latest commit

 

History

History
1125 lines (917 loc) · 33.1 KB

advanced.md

File metadata and controls

1125 lines (917 loc) · 33.1 KB

Advanced Features

Production-ready patterns and advanced usage of the TiKV PHP Client.

Table of Contents

  1. Production Patterns
  2. Performance Optimization
  3. Error Handling Strategies
  4. Monitoring and Observability
  5. Security
  6. Multi-Region Considerations

Production Patterns

Connection Management

Long-Running Processes

For daemons, workers, and long-running scripts:

class TiKvWorker
{
    private RawKvClient $client;
    private int $operationsCount = 0;
    private const RECONNECT_AFTER = 10000;
    
    public function __construct(array $pdEndpoints)
    {
        $this->connect($pdEndpoints);
    }
    
    private function connect(array $pdEndpoints): void
    {
        $this->client = RawKvClient::create($pdEndpoints);
        $this->operationsCount = 0;
    }
    
    public function processJob($job): void
    {
        // Periodic reconnection to prevent stale connections
        if ($this->operationsCount >= self::RECONNECT_AFTER) {
            $this->client->close();
            $this->connect(['127.0.0.1:2379']);
        }
        
        try {
            // Process job
            $this->client->put($job['key'], $job['value']);
            $this->operationsCount++;
        } catch (TiKvException $e) {
            // Reconnect on error
            $this->client->close();
            $this->connect(['127.0.0.1:2379']);
            throw $e;
        }
    }
    
    public function shutdown(): void
    {
        $this->client->close();
    }
}

Connection Pooling (Simulated)

While the client doesn't have explicit connection pooling, gRPC channels are reused:

class TiKvPool
{
    private array $clients = [];
    private array $config;
    
    public function __construct(array $config)
    {
        $this->config = $config;
    }
    
    public function acquire(): RawKvClient
    {
        // Return existing client or create new one
        foreach ($this->clients as $client) {
            // Simple round-robin
            return $client;
        }
        
        $client = RawKvClient::create($this->config['endpoints']);
        $this->clients[] = $client;
        return $client;
    }
    
    public function release(RawKvClient $client): void
    {
        // In PHP, we typically don't release - just reuse
    }
    
    public function closeAll(): void
    {
        foreach ($this->clients as $client) {
            $client->close();
        }
        $this->clients = [];
    }
}

Caching Strategies

Application-Level Caching

Combine TiKV with local cache for hot data:

class CachedTiKvClient
{
    private RawKvClient $client;
    private array $localCache = [];
    private int $cacheTtl;
    
    public function __construct(RawKvClient $client, int $cacheTtl = 60)
    {
        $this->client = $client;
        $this->cacheTtl = $cacheTtl;
    }
    
    public function get(string $key): ?string
    {
        // Check local cache
        if (isset($this->localCache[$key])) {
            $entry = $this->localCache[$key];
            if ($entry['expires'] > time()) {
                return $entry['value'];
            }
            unset($this->localCache[$key]);
        }
        
        // Fetch from TiKV
        $value = $this->client->get($key);
        
        // Cache if found
        if ($value !== null) {
            $this->localCache[$key] = [
                'value' => $value,
                'expires' => time() + $this->cacheTtl,
            ];
        }
        
        return $value;
    }
    
    public function put(string $key, string $value, int $ttl = 0): void
    {
        $this->client->put($key, $value, $ttl);
        
        // Invalidate local cache
        unset($this->localCache[$key]);
    }
    
    public function invalidate(string $pattern): void
    {
        foreach ($this->localCache as $key => $entry) {
            if (str_starts_with($key, $pattern)) {
                unset($this->localCache[$key]);
            }
        }
    }
}

Cache Warming

Pre-populate cache before high-traffic periods:

function warmCache(RawKvClient $client, array $hotKeys): void
{
    // Batch fetch all hot keys
    $values = $client->batchGet($hotKeys);
    
    // Store in application cache (Redis, Memcached, etc.)
    foreach ($values as $key => $value) {
        if ($value !== null) {
            redis()->setex("tikv:$key", 300, $value);
        }
    }
}

// Usage
$hotKeys = ['config:app', 'user:1', 'product:top:10'];
warmCache($client, $hotKeys);

Distributed Patterns

Leader Election

Prerequisite: compareAndSwap() and putIfAbsent() require atomic mode — see Atomic Mode.

class LeaderElection
{
    private RawKvClient $client;
    private string $nodeId;
    private string $lockKey;
    private int $ttl;
    
    public function __construct(
        RawKvClient $client,
        string $nodeId,
        string $resource,
        int $ttl = 30
    ) {
        $this->client = $client;
        $this->nodeId = $nodeId;
        $this->lockKey = "leader:$resource";
        $this->ttl = $ttl;
    }
    
    public function tryBecomeLeader(): bool
    {
        $existing = $this->client->putIfAbsent($this->lockKey, $this->nodeId, $this->ttl);
        return $existing === null;
    }
    
    public function renewLeadership(): bool
    {
        $current = $this->client->get($this->lockKey);
        
        if ($current === $this->nodeId) {
            // Still leader, renew
            $this->client->put($this->lockKey, $this->nodeId, $this->ttl);
            return true;
        }
        
        return false;
    }
    
    public function stepDown(): void
    {
        $current = $this->client->get($this->lockKey);
        if ($current === $this->nodeId) {
            $this->client->delete($this->lockKey);
        }
    }
    
    public function isLeader(): bool
    {
        return $this->client->get($this->lockKey) === $this->nodeId;
    }
}

// Usage
$client->setAtomicForCAS(true);  // required: tryBecomeLeader() uses putIfAbsent()
$election = new LeaderElection($client, 'node-1', 'scheduler');

if ($election->tryBecomeLeader()) {
    echo "Became leader\n";
    
    // Start heartbeat
    while (true) {
        sleep(10);
        if (!$election->renewLeadership()) {
            echo "Lost leadership\n";
            break;
        }
        // Do leader work
    }
}

Distributed Counter

class DistributedCounter
{
    private RawKvClient $client;
    private string $key;
    
    public function __construct(RawKvClient $client, string $name)
    {
        $this->client = $client;
        $this->key = "counter:$name";
    }
    
    public function increment(int $delta = 1): int
    {
        $maxRetries = 10;
        
        for ($i = 0; $i < $maxRetries; $i++) {
            $current = $this->client->get($this->key) ?? '0';
            $next = (int)$current + $delta;
            
            $result = $this->client->compareAndSwap(
                $this->key,
                $current,
                (string)$next
            );
            
            if ($result->swapped) {
                return $next;
            }
            
            // Exponential backoff
            usleep(1000 * (2 ** $i));  // 1ms, 2ms, 4ms, 8ms...
        }
        
        throw new RuntimeException("Failed to increment counter after $maxRetries retries");
    }
    
    public function get(): int
    {
        return (int)($this->client->get($this->key) ?? '0');
    }
    
    public function reset(): void
    {
        $this->client->put($this->key, '0');
    }
}

// Usage
$client->setAtomicForCAS(true);  // required: increment() uses compareAndSwap()
$counter = new DistributedCounter($client, 'page_views');
$newCount = $counter->increment();
echo "Page views: $newCount\n";

Performance Optimization

Batch Optimization

Optimal Batch Sizes

class BatchOptimizer
{
    private const MAX_BATCH_SIZE = 1000;
    private const TARGET_REGION_SIZE = 100;  // Keys per region
    
    public static function optimizeBatches(array $keys): array
    {
        // Split large batches into optimal chunks
        if (count($keys) <= self::MAX_BATCH_SIZE) {
            return [$keys];
        }
        
        return array_chunk($keys, self::MAX_BATCH_SIZE);
    }
    
    public static function parallelBatchGet(
        RawKvClient $client,
        array $keys,
        int $concurrency = 5
    ): array {
        $batches = self::optimizeBatches($keys);
        $results = [];
        
        // Process batches with limited concurrency
        $queue = new SplQueue();
        foreach ($batches as $batch) {
            $queue->enqueue($batch);
        }
        
        while (!$queue->isEmpty()) {
            $batch = $queue->dequeue();
            $batchResults = $client->batchGet($batch);
            $results = array_merge($results, $batchResults);
        }
        
        return $results;
    }
}

Write Batching

class WriteBuffer
{
    private RawKvClient $client;
    private array $buffer = [];
    private int $maxSize;
    private float $maxWaitMs;
    private float $lastFlush;
    
    public function __construct(
        RawKvClient $client,
        int $maxSize = 100,
        float $maxWaitMs = 1000
    ) {
        $this->client = $client;
        $this->maxSize = $maxSize;
        $this->maxWaitMs = $maxWaitMs;
        $this->lastFlush = microtime(true) * 1000;
    }
    
    public function put(string $key, string $value, int $ttl = 0): void
    {
        $this->buffer[$key] = ['value' => $value, 'ttl' => $ttl];
        
        if (count($this->buffer) >= $this->maxSize) {
            $this->flush();
        }
    }
    
    public function shouldFlush(): bool
    {
        $elapsed = (microtime(true) * 1000) - $this->lastFlush;
        return $elapsed >= $this->maxWaitMs || count($this->buffer) >= $this->maxSize;
    }
    
    public function flush(): void
    {
        if (empty($this->buffer)) {
            return;
        }
        
        // Group by TTL for efficient batching
        $byTtl = [];
        foreach ($this->buffer as $key => $data) {
            $ttl = $data['ttl'];
            $byTtl[$ttl][$key] = $data['value'];
        }
        
        // Batch put each TTL group
        foreach ($byTtl as $ttl => $pairs) {
            $this->client->batchPut($pairs, $ttl);
        }
        
        $this->buffer = [];
        $this->lastFlush = microtime(true) * 1000;
    }
    
    public function __destruct()
    {
        $this->flush();
    }
}

// Usage
$buffer = new WriteBuffer($client, maxSize: 500, maxWaitMs: 500);

foreach ($data as $key => $value) {
    $buffer->put($key, $value);
}

$buffer->flush();  // Ensure all writes are persisted

Scan Optimization

Pagination

For reading ranges larger than one scan page, do not hand-roll a paginator — the client ships a lazy, auto-paginating scan iterator that does the paging for you with constant memory (one page of $batchSize rows at a time):

// Range iterator: [startKey, endKey)
foreach ($client->scanIterator('user:', 'user;', batchSize: 500) as $key => $value) {
    processUser($key, $value);
}

// Prefix iterator (end key calculated automatically)
foreach ($client->scanPrefixIterator('log:2024-01-', batchSize: 1000, keyOnly: true) as $key => $_) {
    countLogEntry($key); // $value is null when keyOnly is true
}

The iterator fetches a page of up to batchSize rows (default 1024, bounds 1 <= batchSize <= 10240) per underlying scan() call and continues from after the page's last key until the range is exhausted. batchSize is validated immediately; scan RPCs (and their RegionException / GrpcException failures) happen lazily during iteration. The iterator is rewindable — rewind() re-scans from the original start key — and there is no reverse variant. Full semantics: Iterating Large Ranges.

An unbounded scan($start, $end, limit: 0) (or scanPrefix($prefix, limit: 0)) returns the whole range by paging internally (up to MAX_SCAN_LIMIT = 10240 rows per RPC) and buffering every row (issue #191). Within a page that spans several regions, the per-region reads fan out concurrently in windows of at most options['maxConcurrency'] regions (default 16): each window is dispatched with the remaining page budget, awaited, trimmed in key order, and the next window is only sent while budget remains. A region/transport error (or a region that split after enumeration) transparently falls back to the sequential retrying path. Because the complete result is still held in memory, an unbounded scan is guarded by options['maxScanRows'] (default 100000) and throws ScanLimitExceededException once the accumulated rows exceed that — never a silent truncation. The guard is checked per fetched page (after the page is buffered), so a guard smaller than the page size still reads a whole page before throwing. For very large ranges, or to keep memory flat, prefer scanIterator(), which keeps memory to a single page.

If you need whole pages rather than row-by-row iteration (e.g. for a worker queue), iterate and buffer rows yourself instead of computing "next key" arithmetic by hand:

$page = [];
$flush = function (array $page): void {
    handlePage($page);
};

foreach ($client->scanPrefixIterator('user:', batchSize: 1000) as $row) {
    $page[] = $row;
    if (count($page) >= 1000) {
        $flush($page);
        $page = [];
    }
}
if ($page !== []) {
    $flush($page);
}

Note: an end key for a prefix is not obtained by incrementing the prefix's last byte when it is 0xFF — the correct rule (RawKvSplitter::calculatePrefixEndKey(), used by scanPrefix() and scanPrefixIterator()) trims trailing 0xFF bytes and increments the preceding byte. Hand-rolled chr($lastByte + 1) paginators silently produce a smaller end key for 0xFF-terminated prefixes and yield empty scans. Another reason to prefer the built-in iterators.

Parallel Scanning

function parallelScan(
    RawKvClient $client,
    string $prefix,
    int $workers = 4
): array {
    // Divide key space into ranges
    $ranges = [];
    $chars = '0123456789abcdef';
    $step = strlen($chars) / $workers;
    
    for ($i = 0; $i < $workers; $i++) {
        $start = $prefix . $chars[$i * $step];
        $end = ($i === $workers - 1) 
            ? $prefix . ";"
            : $prefix . $chars[($i + 1) * $step];
        $ranges[] = [$start, $end];
    }
    
    // Scan ranges in parallel (using batchScan).
    // The ranges are fanned out concurrently, capped by
    // options['maxConcurrency'] (default 16, must be >= 1).
    $results = $client->batchScan($ranges, eachLimit: 10000);
    
    // Merge results
    return array_merge(...$results);
}

Error Handling Strategies

Retry Strategies

You do not need your own retry loop. The client already retries all transient TiKV errors internally (NotLeader, EpochNotMatch, ServerIsBusy, StaleCommand, RegionNotFound, gRPC transport errors) through a bounded RetryExecutor. Wrapping client calls in another user-level loop multiplies the total wait — the inner executor can already spend up to 30 attempts / 30 s wall-clock on one operation — the deadline is checked before each attempt and before each backoff sleep (the sleep is clamped to the remaining budget), so occupancy exceeds it only by the duration of the last in-flight RPC (bounded by the gRPC timeout option, not by the retry deadline) — and can pin PHP-FPM workers far longer than either layer intends.

When the attempt cap or the wall-clock deadline is reached, the operation throws CrazyGoat\TiKV\Client\Retry\RetryBudgetExhaustedException (extends TiKvException). Treat it as a hard failure, not as a signal to retry again; it exposes attempts() and elapsedOrBackoffMs() for diagnostics, getPrevious() for the original TiKV error, and getRawKey() for the un-redacted key (the message itself is redacted — issue #269). The two sleep budgets behave differently: exhausting them rethrows the original TiKV error instead.

Built-in retry budgets (full reference incl. per-error backoff strategies: docs/configuration.md — Retry and Backoff):

Budget Default Exhaustion surfaces as Tunable via
Max attempts 30 (RetryExecutor::DEFAULT_MAX_ATTEMPTS) RetryBudgetExhaustedException RetryExecutor constructor (no client option)
Cumulative backoff budget (maxBackoffMs) 20 000 ms the original TiKvException client constructor argument
ServerBusy sleep budget (serverBusyBudgetMs) 60 000 ms (RawKvClient::DEFAULT_SERVER_BUSY_BUDGET_MS) the original TiKvException RawKvClient constructor argument (fixed at 60 s on the transactional API)
Wall-clock deadline (retryDeadlineMs) 30 000 ms, 0 disables (RetryExecutor::DEFAULT_RETRY_DEADLINE_MS) RetryBudgetExhaustedException options['retryDeadlineMs'] on RawKvClient::create() / TxnKvClient::create()

What you should handle at the application level instead:

use CrazyGoat\TiKV\Client\Retry\RetryBudgetExhaustedException;

try {
    $value = $client->get($key);
} catch (RetryBudgetExhaustedException $e) {
    // The client gave up after exhausting its attempt cap or wall-clock
    // deadline. Log/alert with $e->attempts() and $e->elapsedOrBackoffMs();
    // inspect $e->getPrevious() for the original TiKV error. Surface the
    // failure to your caller or queue the work for later — do NOT
    // immediately retry in a tight loop.
    throw $e;
}

For cross-request availability patterns (failing fast after repeated consecutive failures, serving stale data while TiKV recovers), see the circuit-breaker and graceful-degradation examples below.

Circuit Breaker

class CircuitBreaker
{
    private int $failureThreshold;
    private int $recoveryTimeout;
    private int $failures = 0;
    private ?int $lastFailureTime = null;
    private string $state = 'CLOSED';  // CLOSED, OPEN, HALF_OPEN
    
    public function __construct(
        int $failureThreshold = 5,
        int $recoveryTimeout = 60
    ) {
        $this->failureThreshold = $failureThreshold;
        $this->recoveryTimeout = $recoveryTimeout;
    }
    
    public function call(callable $operation): mixed
    {
        if ($this->state === 'OPEN') {
            if ($this->shouldAttemptReset()) {
                $this->state = 'HALF_OPEN';
            } else {
                throw new RuntimeException("Circuit breaker is OPEN");
            }
        }
        
        try {
            $result = $operation();
            $this->onSuccess();
            return $result;
        } catch (Exception $e) {
            $this->onFailure();
            throw $e;
        }
    }
    
    private function onSuccess(): void
    {
        $this->failures = 0;
        $this->state = 'CLOSED';
    }
    
    private function onFailure(): void
    {
        $this->failures++;
        $this->lastFailureTime = time();
        
        if ($this->failures >= $this->failureThreshold) {
            $this->state = 'OPEN';
        }
    }
    
    private function shouldAttemptReset(): bool
    {
        return (time() - $this->lastFailureTime) >= $this->recoveryTimeout;
    }
}

// Usage
$breaker = new CircuitBreaker(failureThreshold: 3);

$value = $breaker->call(function() use ($client, $key) {
    return $client->get($key);
});

Graceful Degradation

class ResilientCache
{
    private RawKvClient $client;
    private array $fallbackCache = [];
    private bool $tiKvAvailable = true;
    
    public function get(string $key): ?string
    {
        if (!$this->tiKvAvailable) {
            return $this->fallbackCache[$key] ?? null;
        }
        
        try {
            $value = $this->client->get($key);
            
            // Update fallback cache
            if ($value !== null) {
                $this->fallbackCache[$key] = $value;
            }
            
            return $value;
        } catch (TiKvException $e) {
            $this->tiKvAvailable = false;
            
            // Schedule recovery check
            $this->scheduleRecovery();
            
            // Return from fallback
            return $this->fallbackCache[$key] ?? null;
        }
    }
    
    private function scheduleRecovery(): void
    {
        // In real implementation, use a timer or background process
        // For now, just mark for retry on next request
        $this->tiKvAvailable = true;  // Will retry on next call
    }
}

Monitoring and Observability

Metrics Collection

The client ships a built-in, opt-in metrics hook: implement MetricsInterface and pass it via the metrics option. There is no need to wrap client methods in a decorator to time operations — the library already reports RPC counts, per-RPC latency, retries and region-cache behaviour from inside the client, which an external wrapper cannot see.

The interface has six methods (full reference with emitted tag examples: Configuration → Metrics and Observability):

Method Reported when
rpcStarted() An outbound gRPC call is dispatched
rpcCompleted() The call returns — carries duration in ms + success flag
retryAttempted() A retryable error triggers another attempt
regionCacheHit() A region was served from the client's region cache
regionCacheMiss() The cache missed and PD had to be queried
regionInvalidated() A region was dropped from the cache (e.g. retry_region_error, fatal_region_error)

Implementations must never throw; the default is a zero-cost no-op (NoOpMetrics). A ready-made in-memory recorder for tests and benchmarks ships as InMemoryMetrics with getRpcStarted(string $operation), getRpcSucceeded(string $operation), getRetries(string $operation), getCacheHits(string $operation), getMeanLatencyMs(string $operation), getInvalidations(string $reason) (this one keys on the invalidation reason, not an operation tag) and reset().

Production-shaped example — exporting the built-in counters to Prometheus:

use CrazyGoat\TiKV\Client\Observability\MetricsInterface;

final class PrometheusMetrics implements MetricsInterface
{
    public function __construct(
        private readonly \Prometheus\CollectorRegistry $registry,
        private readonly string $namespace = 'tikv_client',
    ) {
    }

    public function rpcStarted(string $operation): void
    {
        $this->counter('rpc_started_total', 'Outbound gRPC calls', ['operation'])
            ->inc(['operation' => $operation]);
    }

    public function rpcCompleted(string $operation, float $durationMs, bool $success): void
    {
        // Histograms give latency distribution, not just averages.
        $this->histogram('rpc_duration_ms', 'gRPC call duration', [1, 5, 10, 50, 100, 500, 1000, 5000])
            ->observe($durationMs, ['operation' => $operation, 'success' => $success ? '1' : '0']);
    }

    public function retryAttempted(string $backoffType): void
    {
        $this->counter('retries_total', 'Retryable errors retried', ['backoff_type'])
            ->inc(['backoff_type' => $backoffType]);
    }

    public function regionCacheHit(string $operation): void
    {
        $this->counter('region_cache_hits_total', 'Region cache hits', ['operation'])
            ->inc(['operation' => $operation]);
    }

    public function regionCacheMiss(string $operation): void
    {
        $this->counter('region_cache_misses_total', 'Region cache misses', ['operation'])
            ->inc(['operation' => $operation]);
    }

    public function regionInvalidated(string $reason): void
    {
        $this->counter('region_invalidations_total', 'Regions dropped from cache', ['reason'])
            ->inc(['reason' => $reason]);
    }

    private function counter(string $name, string $help, array $labels): \Prometheus\Counter
    {
        return $this->registry->getOrRegisterCounter($this->namespace, $name, $help, $labels);
    }

    private function histogram(string $name, string $help, array $buckets): \Prometheus\Histogram
    {
        // Label names must be declared at registration time or ->observe() throws.
        return $this->registry->getOrRegisterHistogram($this->namespace, $name, $help, ['operation', 'success'], $buckets);
    }
}

// Wire it up:
$metrics = new PrometheusMetrics(new \Prometheus\CollectorRegistry(new \Prometheus\Storage\InMemory()));
$client = RawKvClient::create(
    pdEndpoints: ['127.0.0.1:2379'],
    options: ['metrics' => $metrics],
);

// Counters are also reachable afterwards:
$injected = $client->getMetrics();   // returns whatever was passed via 'metrics'

For tests and quick diagnostics, skip the adapter entirely:

use CrazyGoat\TiKV\Client\Observability\InMemoryMetrics;

$metrics = new InMemoryMetrics();
$client = RawKvClient::create(pdEndpoints: ['127.0.0.1:2379'], options: ['metrics' => $metrics]);
// ... run operations ...

echo $metrics->getMeanLatencyMs('tikvpb.Tikv/RawGet'); // mean latency of completed RawGet RPCs
echo $metrics->getRetries('NotLeader');                 // retries tagged with the backoff type

Health Checks

class TiKvHealthChecker
{
    private RawKvClient $client;
    private string $healthKey;
    
    public function __construct(RawKvClient $client)
    {
        $this->client = $client;
        $this->healthKey = 'health:check:' . uniqid();
    }
    
    public function check(): array
    {
        $start = microtime(true);
        $checks = [
            'write' => false,
            'read' => false,
            'delete' => false,
            'latency_ms' => 0,
        ];
        
        try {
            // Test write
            $this->client->put($this->healthKey, 'ok');
            $checks['write'] = true;
            
            // Test read
            $value = $this->client->get($this->healthKey);
            $checks['read'] = $value === 'ok';
            
            // Test delete
            $this->client->delete($this->healthKey);
            $value = $this->client->get($this->healthKey);
            $checks['delete'] = $value === null;
            
            $checks['latency_ms'] = (microtime(true) - $start) * 1000;
            $checks['healthy'] = $checks['write'] && $checks['read'] && $checks['delete'];
        } catch (Exception $e) {
            $checks['error'] = $e->getMessage();
            $checks['healthy'] = false;
        }
        
        return $checks;
    }
}

// Usage in monitoring endpoint
$health = new TiKvHealthChecker($client);
$status = $health->check();

if (!$status['healthy']) {
    // Alert on-call, log to monitoring system
    error_log("TiKV health check failed: " . json_encode($status));
}

Security

TLS Best Practices

Use the explicit TLS option keys: caCertFile, clientCertFile and clientKeyFile for files, caCertPem, clientCertPem and clientKeyPem for inline PEM content. The legacy caCert, clientCert and clientKey keys are deprecated — they guess between a file path and inline PEM, and they cannot be restricted to a base directory.

The optional caCertBaseDir and clientCertBaseDir options 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. caCertBaseDir guards caCertFile; clientCertBaseDir guards both clientCertFile and clientKeyFile.

// Production TLS configuration
$options = [
    'tls' => [
        // Always verify server certificate
        'caCertFile' => '/etc/ssl/certs/tikv-ca.crt',
        // Optional: keep the certificate inside one directory
        'caCertBaseDir' => '/etc/ssl/certs',

        // Use client certificates for mutual TLS
        'clientCertFile' => '/etc/ssl/certs/tikv-client.crt',
        'clientKeyFile' => '/etc/ssl/private/tikv-client.key',
        // Optional: both client files must resolve inside this directory
        'clientCertBaseDir' => '/etc/ssl',
    ],
];

// Certificate rotation (reload without restart)
class RotatingTlsClient
{
    private RawKvClient $client;
    private array $config;
    private int $lastReload;

    public function __construct(array $config)
    {
        $this->config = $config;
        $this->reload();
    }

    public function reload(): void
    {
        if (isset($this->client)) {
            $this->client->close();
        }

        // Reload certificates from disk (inline PEM content)
        $options = [
            'tls' => [
                'caCertPem' => file_get_contents($this->config['caCertFile']),
                'clientCertPem' => file_get_contents($this->config['clientCertFile']),
                'clientKeyPem' => file_get_contents($this->config['clientKeyFile']),
            ],
        ];
        
        $this->client = RawKvClient::create(
            $this->config['endpoints'],
            options: $options
        );
        
        $this->lastReload = time();
    }
    
    public function getClient(): RawKvClient
    {
        // Reload every hour
        if (time() - $this->lastReload > 3600) {
            $this->reload();
        }
        
        return $this->client;
    }
}

Data Encryption

For sensitive data, encrypt before storing:

class EncryptedTiKvClient
{
    private RawKvClient $client;
    private string $encryptionKey;
    
    public function put(string $key, string $value): void
    {
        $encrypted = $this->encrypt($value);
        $this->client->put($key, $encrypted);
    }
    
    public function get(string $key): ?string
    {
        $encrypted = $this->client->get($key);
        return $encrypted !== null ? $this->decrypt($encrypted) : null;
    }
    
    private function encrypt(string $data): string
    {
        $iv = random_bytes(16);
        $encrypted = openssl_encrypt(
            $data,
            'AES-256-GCM',
            $this->encryptionKey,
            OPENSSL_RAW_DATA,
            $iv,
            $tag
        );
        return base64_encode($iv . $tag . $encrypted);
    }
    
    private function decrypt(string $data): string
    {
        $decoded = base64_decode($data);
        $iv = substr($decoded, 0, 16);
        $tag = substr($decoded, 16, 16);
        $ciphertext = substr($decoded, 32);
        
        return openssl_decrypt(
            $ciphertext,
            'AES-256-GCM',
            $this->encryptionKey,
            OPENSSL_RAW_DATA,
            $iv,
            $tag
        );
    }
}

Multi-Region Considerations

Cross-Region Latency

When TiKV spans multiple regions:

class MultiRegionClient
{
    private array $regionClients = [];
    
    public function __construct(array $regionConfigs)
    {
        foreach ($regionConfigs as $region => $config) {
            $this->regionClients[$region] = RawKvClient::create($config['endpoints']);
        }
    }
    
    public function get(string $key, string $preferredRegion = 'local'): ?string
    {
        // Try preferred region first
        $client = $this->regionClients[$preferredRegion];
        $value = $client->get($key);
        
        if ($value !== null) {
            return $value;
        }
        
        // Fallback to other regions
        foreach ($this->regionClients as $region => $client) {
            if ($region === $preferredRegion) {
                continue;
            }
            
            $value = $client->get($key);
            if ($value !== null) {
                return $value;
            }
        }
        
        return null;
    }
}

Data Locality

Route requests to nearest TiKV:

class LocalityAwareRouter
{
    private string $localRegion;
    private array $regionClients;
    
    public function getClientForKey(string $key): RawKvClient
    {
        // Determine which region owns this key
        $region = $this->determineRegion($key);
        
        // If local region has replica, use it
        if ($this->hasLocalReplica($region)) {
            return $this->regionClients[$this->localRegion];
        }
        
        // Otherwise use the owning region
        return $this->regionClients[$region];
    }
    
    private function determineRegion(string $key): string
    {
        // Use key prefix or hash to determine region
        if (str_starts_with($key, 'us:')) return 'us';
        if (str_starts_with($key, 'eu:')) return 'eu';
        if (str_starts_with($key, 'ap:')) return 'ap';
        return 'default';
    }
}

See Also