Files
predis/tests/Predis/Command/Redis/HIMPORT_Test.php
T
Vladyslav Vildanov ac3142abfe Added experimental support for HIMPORT bulk hash import feature (#1712)
* Added support for HIMPORT bulk hash import feature

* Fixed pre-server execution registry miutating, added pre-loaded registry feature

* Refactored retry logic to use existing configuration

* Marked feature as experimental

* Remvoed non-ASCII chars

* Ensure IteratorAggregator interface is enforced

* Fixed bug with updateCachableException adds duplicate entries

* Added explicit reconnect for Relay tests

* Testing with custom image

* Removed redundant comment

* Fixed exception handling

* Removed non-ASCII char

* Fixed merged conflict bugs
2026-08-10 10:54:34 +03:00

911 lines
33 KiB
PHP

<?php
/*
* This file is part of the Predis package.
*
* (c) 2009-2020 Daniele Alessandri
* (c) 2021-2026 Till Krüss
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Command\Redis;
use Predis\ClientInterface;
use Predis\Command\RawCommand;
use Predis\Connection\ConnectionException;
use Predis\Response\ServerException;
use Predis\Response\Status;
use Predis\Retry\Retry;
use Predis\Retry\Strategy\NoBackoff;
/**
* @group commands
* @group realm-hash
*/
class HIMPORT_Test extends PredisCommandTestCase
{
/**
* {@inheritdoc}
*/
protected function getExpectedCommand(): string
{
return HIMPORT::class;
}
/**
* {@inheritdoc}
*/
protected function getExpectedId(): string
{
return 'HIMPORT';
}
/**
* @group disconnected
* @dataProvider argumentsProvider
*/
public function testFilterArguments(array $actualArguments, array $expectedArguments): void
{
$command = $this->getCommand();
$command->setArguments($actualArguments);
$this->assertSame($expectedArguments, $command->getArguments());
}
/**
* @group disconnected
*/
public function testParseResponse(): void
{
$this->assertSame(1, $this->getCommand()->parseResponse(1));
$this->assertEquals(new Status('OK'), $this->getCommand()->parseResponse(new Status('OK')));
}
/**
* @group disconnected
* @dataProvider prefixKeysProvider
*/
public function testPrefixKeys(array $arguments, array $expectedArguments): void
{
$command = $this->getCommand();
$command->setArguments($arguments);
$command->prefixKeys('prefix:');
$this->assertSame($expectedArguments, $command->getArguments());
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testPrepareAndSetCreateHashFromSharedFieldset(): void
{
$redis = $this->getClient();
$this->assertEquals('OK', $redis->himport->prepare('shared', ['name', 'email', 'age']));
$this->assertEquals('OK', $redis->himport->set('shared:1', 'shared', ['alice', 'alice@example.com', '25']));
$this->assertSame('alice', $redis->hget('shared:1', 'name'));
// Hash enumeration order is not guaranteed to match the PREPARE order
// (the server keeps a canonical internal order), so compare sorted.
$hash = $redis->hgetall('shared:1');
ksort($hash);
$this->assertSame(['age' => '25', 'email' => 'alice@example.com', 'name' => 'alice'], $hash);
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testValuesMapByEachFieldsetsOwnOrder(): void
{
$redis = $this->getClient();
$redis->himport->prepare('order1', ['a', 'b', 'c']);
$redis->himport->prepare('order2', ['c', 'b', 'a']);
$redis->himport->set('order:key1', 'order1', ['va1', 'vb1', 'vc1']);
$redis->himport->set('order:key2', 'order2', ['vc2', 'vb2', 'va2']);
$this->assertSame('va1', $redis->hget('order:key1', 'a'));
$this->assertSame('va2', $redis->hget('order:key2', 'a'));
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testSetFullyReplacesExistingHash(): void
{
$redis = $this->getClient();
$redis->hset('replace:1', 'stale', 'value');
$redis->himport->prepare('shared', ['name', 'age']);
$redis->himport->set('replace:1', 'shared', ['bob', '30']);
// Full replace: the pre-existing "stale" field must be gone.
$hash = $redis->hgetall('replace:1');
ksort($hash);
$this->assertSame(['age' => '30', 'name' => 'bob'], $hash);
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testDiscardReturnsRemovalCount(): void
{
$redis = $this->getClient();
$redis->himport->prepare('shared', ['name']);
$this->assertSame(1, $redis->himport->discard('shared'));
$this->assertSame(0, $redis->himport->discard('shared'));
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testDiscardAllReturnsRemovalCount(): void
{
$redis = $this->getClient();
$redis->himport->prepare('fs1', ['a']);
$redis->himport->prepare('fs2', ['b']);
$this->assertSame(2, $redis->himport->discardAll());
$this->assertSame(0, $redis->himport->discardAll());
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testPrepareAndSetResp3(): void
{
$redis = $this->getResp3Client();
$this->assertEquals('OK', $redis->himport->prepare('shared', ['name', 'age']));
$this->assertEquals('OK', $redis->himport->set('shared:1', 'shared', ['carol', '40']));
$this->assertSame('carol', $redis->hget('shared:1', 'name'));
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testThrowsExceptionOnDuplicateFieldName(): void
{
$this->expectException(ServerException::class);
$this->expectExceptionMessage('duplicate field name');
$redis = $this->getClient();
$redis->himport->prepare('shared', ['name', 'name']);
}
/**
* A PREPARE the server rejects must not leave a registry entry: a later SET
* reports the fieldset as unknown rather than re-raising the prepare error.
*
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testRejectedPrepareDoesNotPoisonRegistry(): void
{
$redis = $this->getClient();
try {
$redis->himport->prepare('bad', ['name', 'name']);
$this->fail('Expected a "duplicate field name" error');
} catch (ServerException $exception) {
$this->assertStringContainsString('duplicate field name', $exception->getMessage());
}
try {
$redis->himport->set('bad:1', 'bad', ['alice']);
$this->fail('Expected a "no such fieldset" error');
} catch (ServerException $exception) {
$this->assertStringContainsString('no such fieldset', $exception->getMessage());
$this->assertStringNotContainsString('duplicate field name', $exception->getMessage());
}
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testThrowsExceptionOnUnknownFieldset(): void
{
$this->expectException(ServerException::class);
$this->expectExceptionMessage('no such fieldset');
// Fieldset never declared through the container: nothing to recover
// from, so the server error is propagated unchanged.
$redis = $this->getClient();
$redis->himport->set('shared:1', 'missing', ['value']);
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testThrowsExceptionOnWrongType(): void
{
$this->expectException(ServerException::class);
$this->expectExceptionMessage('Operation against a key holding the wrong kind of value');
$redis = $this->getClient();
$redis->set('himport:string', 'a');
$redis->himport->prepare('shared', ['name']);
$redis->himport->set('himport:string', 'shared', ['value']);
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testRawCommandFormIsSupported(): void
{
$redis = $this->getClient();
// The lower-level raw form (no client-side registry or auto-recovery)
// remains available for pipelines and explicit connection control.
$this->assertEquals('OK', $redis->himport('PREPARE', 'shared', 'name', 'age'));
$this->assertEquals('OK', $redis->himport('SET', 'shared:1', 'shared', 'dave', '50'));
$this->assertSame('dave', $redis->hget('shared:1', 'name'));
$this->assertSame(1, $redis->himport('DISCARD', 'shared'));
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testMultiplePreparesAndDiscardsKeepRegistryAndSessionCommandsCoherent(): void
{
$redis = $this->getClient();
$registry = $redis->getOptions()->himport->getRegistry();
$connection = $redis->getConnection();
$redis->himport->prepare('fs1', ['a']);
$redis->himport->prepare('fs2', ['b', 'c']);
$redis->himport->prepare('fs3', ['d']);
// Both the client-side registry and the connection's replay queue track
// every live fieldset, keyed and insertion-ordered.
$this->assertSame(['fs1', 'fs2', 'fs3'], array_keys($registry->all()));
$this->assertSame(
['himport:fs1', 'himport:fs2', 'himport:fs3'],
array_keys($connection->getSessionCommands())
);
// A single discard removes exactly one fieldset from both stores.
$this->assertSame(1, $redis->himport->discard('fs2'));
$this->assertSame(['fs1', 'fs3'], array_keys($registry->all()));
$this->assertSame(
['himport:fs1', 'himport:fs3'],
array_keys($connection->getSessionCommands())
);
// Re-preparing an existing name replaces its field list (last wins).
$redis->himport->prepare('fs1', ['a', 'x']);
$this->assertSame(['a', 'x'], $registry->get('fs1'));
// DISCARDALL clears both stores and reports the removal count.
$this->assertSame(2, $redis->himport->discardAll());
$this->assertSame([], $registry->all());
$this->assertSame([], $connection->getSessionCommands());
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testReconnectTransparentlyReplaysPreparedFieldsets(): void
{
$redis = $this->getClient();
$redis->himport->prepare('shared', ['name', 'age']);
// Simulate a dropped connection mid-ingestion. The pinned PREPARE is
// replayed when the connection is re-established, so the SET succeeds.
$redis->getConnection()->disconnect();
$this->assertEquals('OK', $this->setAfterReconnect($redis, 'shared:1', 'shared', ['erin', '28']));
$this->assertSame('erin', $redis->hget('shared:1', 'name'));
$this->assertSame('28', $redis->hget('shared:1', 'age'));
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testReactiveRecoveryRePreparesWhenReplayIsUnavailable(): void
{
// Recovery reuses the connection's Retry policy, so it must be enabled.
$redis = $this->createClient(['retry' => new Retry(new NoBackoff(), 1)]);
$redis->himport->prepare('shared', ['name', 'age']);
// Drop the proactive replay entry to emulate a connection that never
// received the PREPARE (e.g. a new cluster node reached via redirection).
$redis->getConnection()->removeSessionCommandsByPrefix('himport:');
$redis->getConnection()->disconnect();
// The first SET hits "no such fieldset"; the container re-prepares from
// the registry on the executing connection and retries exactly once.
$this->assertEquals('OK', $this->setAfterReconnect($redis, 'shared:1', 'shared', ['frank', '33']));
$this->assertSame('frank', $redis->hget('shared:1', 'name'));
$this->assertSame('33', $redis->hget('shared:1', 'age'));
// Recovery re-pins the command for subsequent reconnects.
$this->assertArrayHasKey('himport:shared', $redis->getConnection()->getSessionCommands());
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testAutoPrepareDisabledPropagatesErrorWithoutRecovering(): void
{
$redis = $this->createClient(null, ['himport' => ['auto_prepare' => false]]);
$redis->himport->prepare('shared', ['name', 'age']);
$redis->getConnection()->removeSessionCommandsByPrefix('himport:');
$redis->getConnection()->disconnect();
$this->expectException(ServerException::class);
$this->expectExceptionMessage('no such fieldset');
$this->setAfterReconnect($redis, 'shared:1', 'shared', ['grace', '41']);
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testPipelineRunsPrepareAndSetsOnASingleConnection(): void
{
$redis = $this->getClient();
// The intended high-throughput pattern: PREPARE followed by dependent
// SETs, back-to-back on one connection, using the raw command form.
$responses = $redis->pipeline(function ($pipe) {
$pipe->himport('PREPARE', 'shared', 'name', 'age');
$pipe->himport('SET', 'shared:1', 'shared', 'heidi', '22');
$pipe->himport('SET', 'shared:2', 'shared', 'ivan', '44');
});
$this->assertEquals('OK', $responses[0]);
$this->assertEquals('OK', $responses[1]);
$this->assertEquals('OK', $responses[2]);
$this->assertSame('heidi', $redis->hget('shared:1', 'name'));
$this->assertSame('ivan', $redis->hget('shared:2', 'name'));
}
/**
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testTransactionRunsPrepareAndSetOnASingleConnection(): void
{
$redis = $this->getClient();
$responses = $redis->transaction(function ($tx) {
$tx->himport('PREPARE', 'shared', 'name', 'age');
$tx->himport('SET', 'shared:1', 'shared', 'judy', '37');
});
$this->assertEquals('OK', $responses[0]);
$this->assertEquals('OK', $responses[1]);
$hash = $redis->hgetall('shared:1');
ksort($hash);
$this->assertSame(['age' => '37', 'name' => 'judy'], $hash);
}
/**
* Full lifecycle: PREPARE enables SET, DISCARD ends it, and a SET issued
* afterwards raises the server error (the registry is cleared by DISCARD, so
* auto-recovery does not mask it). Keys already written survive the discard.
*
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testFieldsetLifecycleFromPrepareThroughDiscard(): void
{
$redis = $this->getClient();
$redis->himport->prepare('shared', ['name', 'age']);
$this->assertEquals('OK', $redis->himport->set('shared:1', 'shared', ['alice', '25']));
$this->assertEquals('OK', $redis->himport->set('shared:2', 'shared', ['bob', '30']));
$this->assertSame('alice', $redis->hget('shared:1', 'name'));
$this->assertSame(1, $redis->himport->discard('shared'));
// Keys created through the fieldset are ordinary hashes and are not
// affected by discarding it.
$this->assertSame('bob', $redis->hget('shared:2', 'name'));
// From here on, SET against the discarded fieldset must error.
$this->expectException(ServerException::class);
$this->expectExceptionMessage('no such fieldset');
$redis->himport->set('shared:3', 'shared', ['carol', '40']);
}
/**
* DISCARDALL ends every fieldset at once; each subsequent SET errors.
*
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testDiscardAllStopsEverySubsequentSet(): void
{
$redis = $this->getClient();
$redis->himport->prepare('fs1', ['a']);
$redis->himport->prepare('fs2', ['b']);
$this->assertEquals('OK', $redis->himport->set('k1', 'fs1', ['1']));
$this->assertEquals('OK', $redis->himport->set('k2', 'fs2', ['2']));
$this->assertSame(2, $redis->himport->discardAll());
// Previously written keys survive.
$this->assertSame('1', $redis->hget('k1', 'a'));
foreach (['fs1', 'fs2'] as $fieldset) {
try {
$redis->himport->set('k_' . $fieldset, $fieldset, ['9']);
$this->fail("Expected 'no such fieldset' for {$fieldset}");
} catch (ServerException $exception) {
$this->assertStringContainsString('no such fieldset', $exception->getMessage());
}
}
}
/**
* A fieldset already present on the connection but never declared through the
* container (prepared out of band with the raw command, or inherited on a
* persistent connection) is usable by the container's set(): the write only
* needs the fieldset on the executing connection, not a registry entry.
*
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testContainerSetUsesFieldsetPreparedOutOfBand(): void
{
$redis = $this->getClient();
// Prepared with the raw form: the server-side fieldset exists on the
// connection, but the client-side registry is left untouched.
$this->assertEquals('OK', $redis->himport('PREPARE', 'shared', 'name', 'age'));
$this->assertFalse($redis->getOptions()->himport->getRegistry()->has('shared'));
$this->assertEquals('OK', $redis->himport->set('shared:1', 'shared', ['alice', '25']));
$this->assertSame('alice', $redis->hget('shared:1', 'name'));
// Still not tracked: the container used the pre-loaded state as-is.
$this->assertFalse($redis->getOptions()->himport->getRegistry()->has('shared'));
}
/**
* Recovery is scoped to fieldsets declared through the container. A pre-loaded
* fieldset the registry does not know about is NOT re-prepared after its
* connection is lost, so the server error surfaces unchanged.
*
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testPreloadedFieldsetIsNotRecoveredAfterReconnect(): void
{
$redis = $this->getClient();
$redis->himport('PREPARE', 'shared', 'name', 'age');
$this->assertEquals('OK', $redis->himport->set('shared:1', 'shared', ['alice', '25']));
// The raw PREPARE pinned nothing for replay and recorded nothing in the
// registry, so after a reconnect the fieldset is simply gone.
$redis->getConnection()->disconnect();
$this->expectException(ServerException::class);
$this->expectExceptionMessage('no such fieldset');
$this->setAfterReconnect($redis, 'shared:2', 'shared', ['bob', '30']);
}
/**
* A fieldset declared through the `himport` client option is prepared on
* demand: a SET referencing it succeeds without an explicit prepare() call.
*
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testConfiguredFieldsetIsPreparedOnDemandWithoutExplicitPrepare(): void
{
// On-demand preparation reuses the connection's Retry policy.
$redis = $this->createClient(['retry' => new Retry(new NoBackoff(), 1)], [
'himport' => ['fieldsets' => ['users' => ['name', 'age']]],
]);
// No prepare() call - the fieldset is known from configuration.
$this->assertEquals('OK', $redis->himport->set('users:1', 'users', ['alice', '25']));
$this->assertEquals('OK', $redis->himport->set('users:2', 'users', ['bob', '30']));
$this->assertSame('alice', $redis->hget('users:1', 'name'));
$this->assertSame('bob', $redis->hget('users:2', 'name'));
}
/**
* On-demand preparation of configured fieldsets uses the auto-prepare
* mechanism; with it disabled and no explicit prepare(), the server error
* is propagated.
*
* @group connected
* @requiresRedisVersion >= 8.9.0
*/
public function testConfiguredFieldsetWithAutoPrepareDisabledIsNotPrepared(): void
{
$redis = $this->createClient(null, [
'himport' => ['fieldsets' => ['users' => ['name', 'age']], 'auto_prepare' => false],
]);
$this->expectException(ServerException::class);
$this->expectExceptionMessage('no such fieldset');
$redis->himport->set('users:1', 'users', ['alice', '25']);
}
/**
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.9.0
*/
public function testClusterFansOutPrepareToEveryMasterAndPinsPerNode(): void
{
$redis = $this->getClient();
$this->assertEquals('OK', $redis->himport->prepare('shared', ['name', 'email', 'age']));
// PREPARE is executed on every master shard and pinned on each node so
// it survives that node's future reconnects.
$masters = iterator_to_array($redis->getConnection()->getIterator());
$this->assertGreaterThan(1, count($masters));
foreach ($masters as $master) {
$this->assertArrayHasKey('himport:shared', $master->getSessionCommands());
}
}
/**
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.9.0
*/
public function testClusterRoutesSetByKeySlotAcrossShards(): void
{
$redis = $this->getClient();
$redis->himport->prepare('shared', ['name', 'age']);
// Keys chosen to land on different shards; each master already holds the
// fanned-out fieldset, so every SET succeeds by normal slot routing.
foreach (['shared:{a}', 'shared:{b}', 'shared:{c}', 'shared:{d}'] as $index => $key) {
$this->assertEquals('OK', $redis->himport->set($key, 'shared', ['user' . $index, (string) (20 + $index)]));
$this->assertSame('user' . $index, $redis->hget($key, 'name'));
}
}
/**
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.9.0
*/
public function testClusterMultiplePreparesAndDiscardManageStatePerNode(): void
{
$redis = $this->getClient();
$redis->himport->prepare('fs1', ['a']);
$redis->himport->prepare('fs2', ['b']);
$registry = $redis->getOptions()->himport->getRegistry();
$this->assertSame(['fs1', 'fs2'], array_keys($registry->all()));
foreach (iterator_to_array($redis->getConnection()->getIterator()) as $master) {
$this->assertSame(
['himport:fs1', 'himport:fs2'],
array_keys($master->getSessionCommands())
);
}
// Discarding fs1 fans out to every master and updates every store.
$this->assertSame(1, $redis->himport->discard('fs1'));
$this->assertSame(['fs2'], array_keys($registry->all()));
foreach (iterator_to_array($redis->getConnection()->getIterator()) as $master) {
$this->assertSame(['himport:fs2'], array_keys($master->getSessionCommands()));
}
$this->assertSame(1, $redis->himport->discardAll());
$this->assertSame([], $registry->all());
foreach (iterator_to_array($redis->getConnection()->getIterator()) as $master) {
$this->assertSame([], $master->getSessionCommands());
}
}
/**
* Full cluster lifecycle across shards: after fan-out PREPARE, SET succeeds
* for keys on every shard; after fan-out DISCARD, SET errors on every shard,
* while hashes already written survive.
*
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.9.0
*/
public function testClusterFieldsetLifecycleAcrossShards(): void
{
$redis = $this->getClient();
$redis->himport->prepare('shared', ['name', 'age']);
$keys = ['shared:{a}', 'shared:{b}', 'shared:{c}', 'shared:{d}'];
foreach ($keys as $i => $key) {
$this->assertEquals('OK', $redis->himport->set($key, 'shared', ['user' . $i, (string) (20 + $i)]));
}
foreach ($keys as $i => $key) {
$this->assertSame('user' . $i, $redis->hget($key, 'name'));
}
// DISCARD fans out to every master; the fieldset is gone cluster-wide.
$this->assertSame(1, $redis->himport->discard('shared'));
// Keys written earlier are unaffected.
$this->assertSame('user0', $redis->hget($keys[0], 'name'));
// A SET on any shard now errors and is not auto-recovered (registry cleared).
foreach ($keys as $key) {
try {
$redis->himport->set($key, 'shared', ['x', '1']);
$this->fail("Expected 'no such fieldset' for {$key}");
} catch (ServerException $exception) {
$this->assertStringContainsString('no such fieldset', $exception->getMessage());
}
}
}
/**
* Per-node state independence: with auto-prepare OFF, removing the fieldset
* from ONE master makes SETs routed to that master fail, while SETs routed to
* other masters keep working. Proves the error appears exactly where the
* server-side state is missing and nowhere else.
*
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.9.0
*/
public function testClusterPerNodeStateWithoutAutoPrepareErrorsOnlyWhereMissing(): void
{
$redis = $this->createClient(null, ['himport' => ['auto_prepare' => false]]);
$redis->himport->prepare('shared', ['name']);
$cluster = $redis->getConnection();
$byNode = $this->groupKeysByNode($redis, $cluster);
if (count($byNode) < 2) {
$this->markTestSkipped('Requires at least two distinct master shards.');
}
$ids = array_keys($byNode);
$victim = $byNode[$ids[0]];
$other = $byNode[$ids[1]];
// Remove the fieldset from the victim master only (direct raw command).
$victim['node']->executeCommand(RawCommand::create('HIMPORT', 'DISCARD', 'shared'));
// SET routed to the victim shard errors; no recovery because it is off.
try {
$redis->himport->set($victim['keys'][0], 'shared', ['alice']);
$this->fail('Expected a "no such fieldset" error on the victim shard');
} catch (ServerException $exception) {
$this->assertStringContainsString('no such fieldset', $exception->getMessage());
}
// SET routed to a shard that still has the fieldset keeps working.
$this->assertEquals('OK', $redis->himport->set($other['keys'][0], 'shared', ['bob']));
$this->assertSame('bob', $redis->hget($other['keys'][0], 'name'));
}
/**
* With auto-prepare ON, a SET routed to a master that is missing the fieldset
* (e.g. a node recycled or reached via redirection) transparently re-prepares
* on that master and succeeds - no error surfaces where we do not want one.
*
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.9.0
*/
public function testClusterAutoPrepareRecoversOnNodeMissingFieldset(): void
{
// Recovery reuses the connection's Retry policy, so enable it.
$redis = $this->createClient(null, ['parameters' => ['retry' => new Retry(new NoBackoff(), 1)]]);
$redis->himport->prepare('shared', ['name']);
$cluster = $redis->getConnection();
$key = 'shared:{a}';
$node = $cluster->getConnectionByCommand(
$redis->createCommand('himport', ['SET', $key, 'shared', 'placeholder'])
);
// Wipe the fieldset from just the node that owns this key's slot.
$node->executeCommand(RawCommand::create('HIMPORT', 'DISCARD', 'shared'));
// The SET hits "no such fieldset", re-prepares on that node, retries once.
$this->assertEquals('OK', $redis->himport->set($key, 'shared', ['carol']));
$this->assertSame('carol', $redis->hget($key, 'name'));
}
/**
* On a cluster, a fieldset pre-loaded directly on the master that owns a key
* (out of band, without the container fan-out or a registry entry) is usable
* by the container's set() for that key.
*
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.9.0
*/
public function testClusterContainerSetUsesFieldsetPreloadedOnOwningNode(): void
{
$redis = $this->getClient();
$cluster = $redis->getConnection();
$key = 'shared:{a}';
$node = $cluster->getConnectionByCommand(
$redis->createCommand('himport', ['SET', $key, 'shared', 'placeholder'])
);
// Load the fieldset only on the node that owns this key's slot; the
// registry stays empty and no fan-out happens.
$node->executeCommand(RawCommand::create('HIMPORT', 'PREPARE', 'shared', 'name', 'age'));
$this->assertFalse($redis->getOptions()->himport->getRegistry()->has('shared'));
$this->assertEquals('OK', $redis->himport->set($key, 'shared', ['dave', '44']));
$this->assertSame('dave', $redis->hget($key, 'name'));
}
/**
* A fieldset declared through the `himport` option is prepared on demand on
* whichever master owns each key - SETs across shards succeed with no
* explicit prepare() and no manual fan-out.
*
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.9.0
*/
public function testClusterConfiguredFieldsetPreparedOnDemandAcrossShards(): void
{
// On-demand preparation reuses the connection's Retry policy.
$redis = $this->createClient(null, [
'himport' => ['fieldsets' => ['users' => ['name', 'age']]],
'parameters' => ['retry' => new Retry(new NoBackoff(), 1)],
]);
foreach (['users:{a}', 'users:{b}', 'users:{c}', 'users:{d}'] as $i => $key) {
$this->assertEquals('OK', $redis->himport->set($key, 'users', ['user' . $i, (string) (20 + $i)]));
}
foreach (['users:{a}', 'users:{b}', 'users:{c}', 'users:{d}'] as $i => $key) {
$this->assertSame('user' . $i, $redis->hget($key, 'name'));
}
}
/**
* Executes an HIMPORT SET, reconnecting first when the backend does not
* reconnect on its own after an explicit disconnect(). The stream backend
* reconnects lazily on the next command, but ext-relay raises
* `RELAY_ERR_IO ("Not connected")` instead, so these tests catch that and
* reconnect to stay backend-agnostic.
*
* @param array $values
* @return mixed
*/
private function setAfterReconnect(ClientInterface $redis, string $key, string $fieldset, array $values)
{
try {
return $redis->himport->set($key, $fieldset, $values);
} catch (ConnectionException $exception) {
$redis->connect();
return $redis->himport->set($key, $fieldset, $values);
}
}
/**
* Groups a spread of hash-tagged keys by the master shard that owns them.
*
* @param \Predis\Client $redis
* @param mixed $cluster Aggregate cluster connection.
* @return array<string, array{node: mixed, keys: string[]}>
*/
private function groupKeysByNode($redis, $cluster): array
{
$byNode = [];
foreach (range('a', 'p') as $tag) {
$key = 'shared:{' . $tag . '}';
$node = $cluster->getConnectionByCommand(
$redis->createCommand('himport', ['SET', $key, 'shared', 'placeholder'])
);
$id = (string) $node;
if (!isset($byNode[$id])) {
$byNode[$id] = ['node' => $node, 'keys' => []];
}
$byNode[$id]['keys'][] = $key;
}
return $byNode;
}
public function argumentsProvider(): array
{
return [
'PREPARE with variadic fields' => [
['PREPARE', 'shared', 'name', 'email', 'age'],
['PREPARE', 'shared', 'name', 'email', 'age'],
],
'PREPARE with fields as array' => [
['PREPARE', 'shared', ['name', 'email', 'age']],
['PREPARE', 'shared', 'name', 'email', 'age'],
],
'SET with values as array' => [
['SET', 'shared:1', 'shared', ['alice', 'alice@example.com', '25']],
['SET', 'shared:1', 'shared', 'alice', 'alice@example.com', '25'],
],
'field order preserved verbatim' => [
['PREPARE', 'order', ['c', 'a', 'b']],
['PREPARE', 'order', 'c', 'a', 'b'],
],
'empty string fieldset and field names preserved' => [
['PREPARE', '', ['']],
['PREPARE', '', ''],
],
'DISCARD' => [
['DISCARD', 'shared'],
['DISCARD', 'shared'],
],
'DISCARDALL' => [
['DISCARDALL'],
['DISCARDALL'],
],
'minimal placeholder shape tolerated' => [
['key'],
['key'],
],
];
}
public function prefixKeysProvider(): array
{
return [
'SET prefixes the key at index 1 only' => [
['SET', 'shared:1', 'shared', 'alice', '25'],
['SET', 'prefix:shared:1', 'shared', 'alice', '25'],
],
'PREPARE fieldset name is not prefixed' => [
['PREPARE', 'shared', 'name', 'age'],
['PREPARE', 'shared', 'name', 'age'],
],
'DISCARD fieldset name is not prefixed' => [
['DISCARD', 'shared'],
['DISCARD', 'shared'],
],
'DISCARDALL is untouched' => [
['DISCARDALL'],
['DISCARDALL'],
],
];
}
}