diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e74d171..c4b93cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,9 @@ ### Changes -* PHP: Fix pie install from Packagist by setting preferred-install to source for submodule resolution ([#232](https://github.com/valkey-io/valkey-glide-php/pull/232)) +* PHP: Add `BGSAVE`, `BGSAVE SCHEDULE`, and `BGSAVE CANCEL` commands for standalone and cluster clients ([#236](https://github.com/valkey-io/valkey-glide-php/issues/236)) * PHP: Fix pie install from Packagist using PIE pre-packaged-source download method ([#233](https://github.com/valkey-io/valkey-glide-php/pull/233)) +* PHP: Fix pie install from Packagist by setting preferred-install to source for submodule resolution ([#232](https://github.com/valkey-io/valkey-glide-php/pull/232)) * PHP: Fix pie install ([#229](https://github.com/valkey-io/valkey-glide-php/pull/229)) * PHP: Set CLIENT SETINFO lib-name=GlidePHP and lib-ver to extension version for proper client identification * PHP: Add address resolver support for standalone and cluster clients, allowing custom host/port remapping at connection time ([#196](https://github.com/valkey-io/valkey-glide-php/pull/196)) diff --git a/tests/ValkeyGlideBaseTest.php b/tests/ValkeyGlideBaseTest.php index f52c773d..5d2d80a3 100644 --- a/tests/ValkeyGlideBaseTest.php +++ b/tests/ValkeyGlideBaseTest.php @@ -323,4 +323,23 @@ protected function skipIfDnsNotEnabled(): void $this->markTestSkipped('DNS tests are disabled. Set VALKEY_GLIDE_DNS_TESTS_ENABLED=1 to enable.'); } } + + /** + * Waits until the given condition callback returns true, polling every 100ms. + * + * @param callable $condition A callback that returns true when the condition is met. + * @param int $timeoutSeconds Maximum time to wait in seconds. + * @param string $message Failure message if timeout is reached. + */ + protected function waitFor(callable $condition, int $timeoutSeconds = 10, string $message = 'waitFor timed out'): void + { + $start = time(); + while (time() - $start < $timeoutSeconds) { + if ($condition()) { + return; + } + usleep(100000); // 100ms + } + $this->fail($message); + } } diff --git a/tests/ValkeyGlideClusterTest.php b/tests/ValkeyGlideClusterTest.php index 4ce73566..dd7162e2 100644 --- a/tests/ValkeyGlideClusterTest.php +++ b/tests/ValkeyGlideClusterTest.php @@ -406,6 +406,178 @@ public function testFlushAll() } } + /** + * Override for cluster - info() requires a route parameter and checks all primaries. + */ + protected function isSaveInProgress(): bool + { + $info = $this->valkey_glide->info('allPrimaries', 'persistence'); + foreach ($info as $nodeInfo) { + if ( + $nodeInfo['rdb_bgsave_in_progress'] == '1' + || $nodeInfo['aof_rewrite_in_progress'] == '1' + ) { + return true; + } + } + return false; + } + + public function testBgSave() + { + $this->waitForSaveNotInProgress(); + + // Test with allPrimaries route - returns array of node => bool + $result = $this->valkey_glide->bgSave('allPrimaries'); + $this->assertIsArray($result); + $this->assertGT(0, count($result)); + foreach ($result as $nodeAddress => $nodeResult) { + $this->assertIsString($nodeAddress); + $this->assertStringContains(':', $nodeAddress); + $this->assertTrue($nodeResult); + } + + $this->waitForSaveNotInProgress(); + + // Test with randomNode route - returns scalar bool + $result = $this->valkey_glide->bgSave('randomNode'); + $this->assertTrue($result); + } + + public function testBgSaveSchedule() + { + $this->waitForSaveNotInProgress(); + + // Test with allPrimaries route - returns array of node => bool + $result = $this->valkey_glide->bgSave('allPrimaries', 'SCHEDULE'); + $this->assertIsArray($result); + $this->assertGT(0, count($result)); + foreach ($result as $nodeAddress => $nodeResult) { + $this->assertIsString($nodeAddress); + $this->assertStringContains(':', $nodeAddress); + $this->assertTrue($nodeResult); + } + + $this->waitForSaveNotInProgress(); + + // Test with randomNode route - returns scalar bool + $result = $this->valkey_glide->bgSave('randomNode', 'SCHEDULE'); + $this->assertTrue($result); + } + + public function testBgSaveCancel() + { + if (!$this->minVersionCheck('8.1.0')) { + $this->markTestSkipped('BGSAVE CANCEL requires Valkey 8.1.0+'); + return; + } + + $this->waitForSaveNotInProgress(); + + // When no save is in progress, CANCEL returns false (not an array) even for + // multi-node routes. This is because the glide-core's response aggregation + // checks all node responses for errors before building the Map result. When + // ALL nodes return a ServerError (no save in progress), the core collapses + // them into a single command_error rather than returning a per-node Map. + // This matches PHPRedis semantics where bgSave() returns false on failure. + // A successful CANCEL (when a save IS in progress) would return an array + // for multi-node routes since at least one node returns a success response. + $result = $this->valkey_glide->bgSave('allPrimaries', 'CANCEL'); + $this->assertFalse($result); + + $result = $this->valkey_glide->bgSave('randomNode', 'CANCEL'); + $this->assertFalse($result); + } + + public function testBgSaveWithReplyLiteral() + { + $this->waitForSaveNotInProgress(); + + $this->valkey_glide->setOption(ValkeyGlide::OPT_REPLY_LITERAL, true); + + // Test with allPrimaries route - returns array of node => string + $result = $this->valkey_glide->bgSave('allPrimaries'); + $this->assertIsArray($result); + foreach ($result as $nodeResult) { + $this->assertIsString($nodeResult); + $this->assertContains($nodeResult, $this->bgsaveResponses()); + } + + $this->waitForSaveNotInProgress(); + + // Test with randomNode route - returns scalar string + $result = $this->valkey_glide->bgSave('randomNode'); + $this->assertIsString($result); + $this->assertContains($result, $this->bgsaveResponses()); + + $this->waitForSaveNotInProgress(); + + // Test SCHEDULE with allPrimaries route - returns array of node => string + $result = $this->valkey_glide->bgSave('allPrimaries', 'SCHEDULE'); + $this->assertIsArray($result); + foreach ($result as $nodeResult) { + $this->assertIsString($nodeResult); + $this->assertContains($nodeResult, $this->bgsaveResponses()); + } + + $this->waitForSaveNotInProgress(); + + // Test SCHEDULE with randomNode route - returns scalar string + $result = $this->valkey_glide->bgSave('randomNode', 'SCHEDULE'); + $this->assertIsString($result); + $this->assertContains($result, $this->bgsaveResponses()); + + if ($this->minVersionCheck('8.1.0')) { + $this->waitForSaveNotInProgress(); + + // CANCEL with no save in progress returns false (see testBgSaveCancel comment) + $result = $this->valkey_glide->bgSave('allPrimaries', 'CANCEL'); + $this->assertFalse($result); + + $result = $this->valkey_glide->bgSave('randomNode', 'CANCEL'); + $this->assertFalse($result); + } + + $this->valkey_glide->setOption(ValkeyGlide::OPT_REPLY_LITERAL, false); + } + + public function testBgSaveBatch() + { + if (!$this->havePipeline()) { + $this->markTestSkipped('Pipeline not supported'); + return; + } + + $this->waitForSaveNotInProgress(); + + $this->valkey_glide->pipeline(); + $this->valkey_glide->bgSave('allPrimaries'); + $result = $this->valkey_glide->exec(); + $this->assertIsArray($result[0]); + foreach ($result[0] as $nodeResult) { + $this->assertTrue($nodeResult); + } + + $this->waitForSaveNotInProgress(); + + $this->valkey_glide->pipeline(); + $this->valkey_glide->bgSave('allPrimaries', 'SCHEDULE'); + $result = $this->valkey_glide->exec(); + $this->assertIsArray($result[0]); + foreach ($result[0] as $nodeResult) { + $this->assertTrue($nodeResult); + } + + if ($this->minVersionCheck('8.1.0')) { + $this->waitForSaveNotInProgress(); + + $this->valkey_glide->pipeline(); + $this->valkey_glide->bgSave('allPrimaries', 'CANCEL'); + $result = $this->valkey_glide->exec(); + $this->assertFalse($result[0]); + } + } + public function testInfo() { $fields = [ diff --git a/tests/ValkeyGlideTest.php b/tests/ValkeyGlideTest.php index 5925d23f..7bb785f1 100644 --- a/tests/ValkeyGlideTest.php +++ b/tests/ValkeyGlideTest.php @@ -2585,6 +2585,125 @@ public function testFlushAll() $this->assertEquals(0, $this->valkey_glide->dbSize()); } + public function testBgSave() + { + $this->waitForSaveNotInProgress(); + + $result = $this->valkey_glide->bgSave(); + $this->assertTrue($result); + } + + public function testBgSaveSchedule() + { + $this->waitForSaveNotInProgress(); + + $result = $this->valkey_glide->bgSave('SCHEDULE'); + $this->assertTrue($result); + } + + public function testBgSaveCancel() + { + if (!$this->minVersionCheck('8.1.0')) { + $this->markTestSkipped('BGSAVE CANCEL requires Valkey 8.1.0+'); + return; + } + + $this->waitForSaveNotInProgress(); + + $result = $this->valkey_glide->bgSave('CANCEL'); + $this->assertFalse($result); + } + + public function testBgSaveWithReplyLiteral() + { + $this->waitForSaveNotInProgress(); + + $this->valkey_glide->setOption(ValkeyGlide::OPT_REPLY_LITERAL, true); + + $result = $this->valkey_glide->bgSave(); + $this->assertIsString($result); + $this->assertContains($result, $this->bgsaveResponses()); + + $this->waitForSaveNotInProgress(); + + $result = $this->valkey_glide->bgSave('SCHEDULE'); + $this->assertIsString($result); + $this->assertContains($result, $this->bgsaveResponses()); + + if ($this->minVersionCheck('8.1.0')) { + $this->waitForSaveNotInProgress(); + + $result = $this->valkey_glide->bgSave('CANCEL'); + $this->assertFalse($result); + } + + $this->valkey_glide->setOption(ValkeyGlide::OPT_REPLY_LITERAL, false); + } + + public function testBgSaveBatch() + { + if (!$this->havePipeline()) { + $this->markTestSkipped('Pipeline not supported'); + return; + } + + $this->waitForSaveNotInProgress(); + + $this->valkey_glide->pipeline(); + $this->valkey_glide->bgSave(); + $result = $this->valkey_glide->exec(); + $this->assertTrue($result[0]); + + $this->waitForSaveNotInProgress(); + + $this->valkey_glide->pipeline(); + $this->valkey_glide->bgSave('SCHEDULE'); + $result = $this->valkey_glide->exec(); + $this->assertTrue($result[0]); + + if ($this->minVersionCheck('8.1.0')) { + $this->waitForSaveNotInProgress(); + + $this->valkey_glide->pipeline(); + $this->valkey_glide->bgSave('CANCEL'); + $result = $this->valkey_glide->exec(); + $this->assertFalse($result[0]); + } + } + + /** + * Valid BGSAVE response strings (used when OPT_REPLY_LITERAL is enabled). + */ + protected function bgsaveResponses(): array + { + return [ + 'Background saving started', + 'Background saving scheduled', + ]; + } + + /** + * Returns true if a background save (RDB or AOF) is currently in progress. + */ + protected function isSaveInProgress(): bool + { + $info = $this->valkey_glide->info('persistence'); + return $info['rdb_bgsave_in_progress'] == '1' + || $info['aof_rewrite_in_progress'] == '1'; + } + + /** + * Helper method to wait for any background save operations to complete. + */ + protected function waitForSaveNotInProgress() + { + $this->waitFor( + fn() => !$this->isSaveInProgress(), + 10, + 'Timed out waiting for background save to complete' + ); + } + public function testTTL() { $this->valkey_glide->set('x', 'y'); diff --git a/valkey_glide.stub.php b/valkey_glide.stub.php index ca6129ae..8224b1a4 100644 --- a/valkey_glide.stub.php +++ b/valkey_glide.stub.php @@ -1153,6 +1153,22 @@ public function flushAll(?bool $sync = null): ValkeyGlide|bool; */ public function flushDB(?bool $sync = null): ValkeyGlide|bool; + /** + * Asynchronously saves the dataset to disk in the background. + * + * When called without a mode argument, initiates a background save immediately. + * When called with "SCHEDULE", schedules a background save to run when possible. + * When called with "CANCEL", aborts all in-progress and scheduled background saves (Valkey 8.1+). + * + * @param string|null $mode Optional mode: null for default, "SCHEDULE" to schedule, + * or "CANCEL" to abort (requires Valkey 8.1+). + * @return ValkeyGlide|bool|string Returns true on success, false on failure. + * With OPT_REPLY_LITERAL enabled, returns the status string instead. + * + * @see https://valkey.io/commands/bgsave + */ + public function bgSave(?string $mode = null): ValkeyGlide|bool|string; + /** * Functions is an API for managing code to be executed on the server. * diff --git a/valkey_glide_cluster.c b/valkey_glide_cluster.c index 66001cee..feb227ed 100644 --- a/valkey_glide_cluster.c +++ b/valkey_glide_cluster.c @@ -977,6 +977,11 @@ FLUSHDB_METHOD_IMPL(ValkeyGlideCluster) FLUSHALL_METHOD_IMPL(ValkeyGlideCluster) /* }}} */ +/* {{{ proto ValkeyGlideCluster::bgSave(mixed route, [string mode]) + * Asynchronously saves the dataset to disk in the background. */ +BGSAVE_METHOD_IMPL(ValkeyGlideCluster) +/* }}} */ + /* {{{ proto ValkeyGlideCluster::dbsize(string key) * proto ValkeyGlideCluster::dbsize(array host_port) */ DBSIZE_METHOD_IMPL(ValkeyGlideCluster) diff --git a/valkey_glide_cluster.stub.php b/valkey_glide_cluster.stub.php index ee03a133..36bc440d 100644 --- a/valkey_glide_cluster.stub.php +++ b/valkey_glide_cluster.stub.php @@ -558,6 +558,14 @@ public function flushAll(mixed $route, bool $async = false): ValkeyGlideCluster| */ public function flushDB(mixed $route, bool $async = false): ValkeyGlideCluster|bool; + /** + * @see ValkeyGlide::bgSave + * + * For multi-node routes, returns an associative array mapping node addresses to results. + * For single-node routes, returns a scalar (bool or string). + */ + public function bgSave(mixed $route, ?string $mode = null): ValkeyGlideCluster|array|bool|string; + /** * @see ValkeyGlide::geoadd */ diff --git a/valkey_glide_commands.c b/valkey_glide_commands.c index ab963182..508d2e54 100644 --- a/valkey_glide_commands.c +++ b/valkey_glide_commands.c @@ -258,6 +258,83 @@ int execute_flushall_command(zval* object, int argc, zval* return_value, zend_cl } } +/* Execute a BGSAVE command using the Valkey Glide client - UNIFIED IMPLEMENTATION */ +int execute_bgsave_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { + valkey_glide_object* valkey_glide; + zval* args = NULL; + int args_count = 0; + zend_bool is_cluster = (ce == get_valkey_glide_cluster_ce()); + char* mode = NULL; + size_t mode_len = 0; + + /* Get ValkeyGlide object */ + valkey_glide = VALKEY_GLIDE_PHP_ZVAL_GET_OBJECT(valkey_glide_object, object); + if (!valkey_glide || !valkey_glide->glide_client) { + return 0; + } + + /* Setup core command arguments */ + core_command_args_t core_args = {0}; + core_args.glide_client = valkey_glide->glide_client; + core_args.cmd_type = BgSave; + core_args.is_cluster = is_cluster; + + if (is_cluster) { + /* Parse parameters for cluster - first parameter is route, optional second is mode */ + if (zend_parse_method_parameters(argc, object, "O*", &object, ce, &args, &args_count) == + FAILURE) { + return 0; + } + + if (args_count == 0) { + /* Need at least the route parameter */ + return 0; + } + + /* Set up routing */ + core_args.has_route = 1; + core_args.route_param = &args[0]; + + /* Get optional mode parameter */ + if (args_count > 1 && Z_TYPE(args[1]) == IS_STRING) { + mode = Z_STRVAL(args[1]); + mode_len = Z_STRLEN(args[1]); + } + } else { + /* Non-cluster case - parse optional mode parameter */ + if (zend_parse_method_parameters(argc, object, "O|s!", &object, ce, &mode, &mode_len) == + FAILURE) { + return 0; + } + } + + /* Add mode option if provided (SCHEDULE or CANCEL) */ + if (mode && mode_len > 0) { + core_args.args[0].type = CORE_ARG_TYPE_STRING; + core_args.args[0].data.string_arg.value = mode; + core_args.args[0].data.string_arg.len = mode_len; + core_args.arg_count = 1; + } + + /* Select processor based on OPT_REPLY_LITERAL: + * - With OPT_REPLY_LITERAL: return raw string (process_core_bgsave_string_result) + * - Without OPT_REPLY_LITERAL: return bool (process_core_bgsave_bool_result) + * This ensures correct types in both normal and batch/pipeline mode. */ + z_result_processor_t processor = valkey_glide->opt_reply_literal + ? process_core_bgsave_string_result + : process_core_bgsave_bool_result; + + /* Execute using unified core framework */ + if (!execute_core_command(valkey_glide, &core_args, NULL, processor, return_value)) { + return 0; + } + if (valkey_glide->is_in_batch_mode) { + /* In batch mode, return $this for method chaining */ + ZVAL_COPY(return_value, object); + } + return 1; +} + /* Execute a TIME command using the Valkey Glide client - UNIFIED IMPLEMENTATION */ int execute_time_command(zval* object, int argc, zval* return_value, zend_class_entry* ce) { valkey_glide_object* valkey_glide; diff --git a/valkey_glide_commands_common.h b/valkey_glide_commands_common.h index 55cf8ceb..a174f94b 100644 --- a/valkey_glide_commands_common.h +++ b/valkey_glide_commands_common.h @@ -203,6 +203,7 @@ int execute_watch_command(zval* object, int argc, zval* return_value, zend_class int execute_unwatch_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); int execute_flushdb_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); int execute_flushall_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); +int execute_bgsave_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); int execute_time_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); int execute_scan_command(zval* object, int argc, zval* return_value, zend_class_entry* ce); int execute_cluster_scan_command(const void* glide_client, @@ -689,6 +690,20 @@ int execute_unlink_command(zval* object, int argc, zval* return_value, zend_clas RETURN_FALSE; \ } +#define BGSAVE_METHOD_IMPL(class_name) \ + PHP_METHOD(class_name, bgSave) { \ + if (execute_bgsave_command(getThis(), \ + ZEND_NUM_ARGS(), \ + return_value, \ + strcmp(#class_name, "ValkeyGlideCluster") == 0 \ + ? get_valkey_glide_cluster_ce() \ + : get_valkey_glide_ce())) { \ + return; \ + } \ + zval_dtor(return_value); \ + RETURN_FALSE; \ + } + #define TIME_METHOD_IMPL(class_name) \ PHP_METHOD(class_name, time) { \ if (execute_time_command(getThis(), \ diff --git a/valkey_glide_core_common.c b/valkey_glide_core_common.c index ecb4679f..75b51992 100644 --- a/valkey_glide_core_common.c +++ b/valkey_glide_core_common.c @@ -313,6 +313,7 @@ int prepare_core_args(core_command_args_t* args, case Wait: case FlushDB: case FlushAll: + case BgSave: case Select: case SwapDb: return prepare_message_args( @@ -1589,6 +1590,9 @@ int process_core_string_result(CommandResponse* response, void* output, zval* re ZVAL_NULL(return_value); } + return 1; + } else if (response->response_type == Ok) { + ZVAL_STRING(return_value, "OK"); return 1; } else if (response->response_type == Null) { ZVAL_FALSE(return_value); @@ -1623,6 +1627,83 @@ int process_core_bool_result(CommandResponse* response, void* output, zval* retu return 1; } +/** + * Generic cluster result processor. + * For single-node responses, delegates to the provided single_node_processor. + * For multi-node (Map) responses, builds an associative array of node => result + * by applying single_node_processor to each node's value. + */ +int process_core_cluster_result(CommandResponse* response, + void* output, + zval* return_value, + z_result_processor_t single_node_processor) { + if (!response) { + ZVAL_FALSE(return_value); + return 0; + } + + if (response->response_type == Map && response->array_value && response->array_value_len > 0) { + /* Multi-node response - return associative array of node => result */ + array_init(return_value); + for (long i = 0; i < response->array_value_len; i++) { + CommandResponse* element = &response->array_value[i]; + CommandResponse* value = element->map_value; + if (element->map_key && element->map_key->string_value && value) { + zval node_result; + single_node_processor(value, output, &node_result); + add_assoc_zval_ex(return_value, + element->map_key->string_value, + element->map_key->string_value_len, + &node_result); + } + } + return 1; + } + + /* Single-node response - delegate directly */ + return single_node_processor(response, output, return_value); +} + +/** + * Single-node BGSAVE bool processor. + * Converts String/Ok responses to true, anything else to false. + */ +static int process_bgsave_single_node_bool(CommandResponse* response, + void* output, + zval* return_value) { + if (!response) { + ZVAL_FALSE(return_value); + return 0; + } + + if (response->response_type == String || response->response_type == Ok) { + ZVAL_TRUE(return_value); + return 1; + } + + ZVAL_FALSE(return_value); + return 1; +} + +/** + * BGSAVE boolean result processor. + * For single-node: returns true on success (String/Ok), false otherwise. + * For multi-node routes: returns an associative array of node => bool. + */ +int process_core_bgsave_bool_result(CommandResponse* response, void* output, zval* return_value) { + return process_core_cluster_result( + response, output, return_value, process_bgsave_single_node_bool); +} + +/** + * BGSAVE string result processor (OPT_REPLY_LITERAL mode). + * For single-node: returns the status string. + * For multi-node routes: returns an associative array of node => string. + */ +int process_core_bgsave_string_result(CommandResponse* response, void* output, zval* return_value) { + return process_core_cluster_result(response, output, return_value, process_core_string_result); +} + /* ==================================================================== * SPECIALIZED COMMAND HELPERS diff --git a/valkey_glide_core_common.h b/valkey_glide_core_common.h index 4e15a402..7eeb739b 100644 --- a/valkey_glide_core_common.h +++ b/valkey_glide_core_common.h @@ -207,6 +207,17 @@ int process_core_string_result(CommandResponse* response, void* output, zval* re /* Boolean result processor */ int process_core_bool_result(CommandResponse* response, void* output, zval* return_value); +/* Generic cluster result processor - applies single_node_processor per node for multi-node routes + */ +int process_core_cluster_result(CommandResponse* response, + void* output, + zval* return_value, + z_result_processor_t single_node_processor); + +/* BGSAVE result processors (use process_core_cluster_result internally) */ +int process_core_bgsave_bool_result(CommandResponse* response, void* output, zval* return_value); +int process_core_bgsave_string_result(CommandResponse* response, void* output, zval* return_value); + /* Array result processor */ int process_core_array_result(CommandResponse* response, void* output, zval* return_value); diff --git a/valkey_z_php_methods.c b/valkey_z_php_methods.c index 3c9d2c41..601d1220 100644 --- a/valkey_z_php_methods.c +++ b/valkey_z_php_methods.c @@ -814,6 +814,10 @@ FLUSHDB_METHOD_IMPL(ValkeyGlide) FLUSHALL_METHOD_IMPL(ValkeyGlide) /* }}} */ +/* {{{ proto string ValkeyGlide::bgSave([string mode]) */ +BGSAVE_METHOD_IMPL(ValkeyGlide) +/* }}} */ + /* {{{ proto array ValkeyGlide::time() */ TIME_METHOD_IMPL(ValkeyGlide) /* }}} */