Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

## [Unreleased]

### Added
- [#97] Disaster-recovery C API bindings in `AdminClient`:
`createSnapshot(string $uid, string $snapCommand)` (backed by
`fdb_database_create_snapshot`, the `fdbcli snapshot` entry point; the UID
must be exactly 32 hexadecimal characters and the command printable ASCII,
validated before the FFI call) and
`forceRecoveryWithDataLoss(string $dcId)` (backed by
`fdb_database_force_recovery_with_data_loss`, the
`fdbcli force_recovery_with_data_loss <dcid>` entry point — data loss is
in the name).
### Changed
- [#97] `AdminClient::forceRecovery()` is reimplemented on top of
`fdb_database_force_recovery_with_data_loss()` and no longer throws
`\LogicException` — it is now a working alias of
`forceRecoveryWithDataLoss()`. This removes the last attempted
special-key write for an operation that the special-key space never
supported (`special_keys_no_module_found`). Destructive happy-path
coverage lives in `tests/Integration/AdminDestructiveOperationsTest.php`,
skipped unless `FDB_ENABLE_DESTRUCTIVE_ADMIN_TESTS=1`.
### Added
- [#94] Client/cluster introspection: `FoundationDB::getClientVersion()`
(backed by `fdb_get_client_version`, the version/build of the loaded
Expand Down
56 changes: 39 additions & 17 deletions docs/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,24 +87,43 @@ $isConsistent = $admin->consistencyCheck(); // bool

## Force Recovery

**Not supported via AdminClient.** `forceRecovery()` is deprecated and throws
`\LogicException` synchronously (after validating its argument). Forced
recovery is performed by the cluster controller over an RPC
(`fdbcli` command `force_recovery_with_data_loss <dcid>`) — there is no
`\xff\xff/management/force_recovery` special key, and a write to that key
fails at commit with `special_keys_no_module_found`. Use the `fdbcli`
`force_recovery_with_data_loss` command instead.
Forced recovery is available via the direct C API entry point
`fdb_database_force_recovery_with_data_loss()` (bound in #97) — the same
RPC the cluster controller and the `fdbcli`
`force_recovery_with_data_loss <dcid>` command use. This replaces the old
special-key approach, which never worked (a write to
`\xff\xff/management/force_recovery` fails at commit with
`special_keys_no_module_found`).

**WARNING: May cause data loss!**
```php
// WARNING: may cause data loss! Use only in emergency situations when the
// primary datacenter is gone.
$admin->forceRecoveryWithDataLoss('dc_id');

// forceRecovery() is an alias and behaves identically.
$admin->forceRecovery('dc_id');
```

The dcId is validated against `[A-Za-z0-9_-]{1,64}` and rejected with
`\InvalidArgumentException` before the FFI call.

## Disaster-Recovery Snapshots

`createSnapshot()` binds `fdb_database_create_snapshot()` — the entry point
behind the `fdbcli` `snapshot` command. The cluster must have snapshot
support configured, otherwise the future resolves to an error.

```php
try {
$admin->forceRecovery('dc_id'); // throws \LogicException
} catch (\LogicException $e) {
// use fdbcli `force_recovery_with_data_loss` instead
}
$admin->createSnapshot(
uid: '0123456789abcdef0123456789abcdef', // 32 hex characters
snapCommand: 'start',
);
```

The UID must be exactly 32 hexadecimal characters (`[0-9a-fA-F]{32}`) and
the snapshot command printable ASCII (0x20–0x7E, 1–256 bytes); both are
validated before the C call.

## Validation contract summary

Every public method that takes a caller-supplied identifier validates it
Expand All @@ -118,7 +137,9 @@ before opening a transaction. The full contract is:
| `includeServer` | server address (host:port) | `[A-Za-z0-9._:-]` | 256 bytes | `\InvalidArgumentException` |
| `rebootWorker` | server address | `[A-Za-z0-9._:-]` | 256 bytes | `\InvalidArgumentException` |
| `configure` | 1 or 2 whitespace-split tokens | `[A-Za-z0-9_-]` per token | 64 bytes | `\InvalidArgumentException`, then `\LogicException` (unsupported operation) |
| `forceRecovery` | dcId | `[A-Za-z0-9_-]` | 64 bytes | `\InvalidArgumentException`, then `\LogicException` (unsupported operation) |
| `forceRecovery` / `forceRecoveryWithDataLoss` | dcId | `[A-Za-z0-9_-]` | 64 bytes | `\InvalidArgumentException` |
| `createSnapshot` | snapshot UID | `[0-9a-fA-F]`, exactly 32 chars | 32 bytes | `\InvalidArgumentException` |
| `createSnapshot` | snapshot command | printable ASCII (0x20–0x7E) | 256 bytes | `\InvalidArgumentException` |

Byte-level safety is shared with `KeyValueLimits`: every Special Key path
spliced together from caller input is also checked against the FDB key size
Expand All @@ -132,9 +153,10 @@ limit (10,000 bytes) and the FFI 32-bit length boundary.
| `deleteTenant` | `string $name` | `void` | Delete a tenant |
| `listTenants` | — | `list<string>` | List all tenants |
| `rebootWorker` | `string $address, bool $checkFile = false, int $suspendDuration = 0` | `void` | Reboot worker |
| `configure` | `string $configuration` | `void` | Deprecated — throws `\LogicException` (unsupported by special keys) |
| `excludeServer` | `string $address` | `void` | Exclude server |
| `configure` | `string $configuration` | `void` | Deprecated — throws `\LogicException` (unsupported by special keys) || `excludeServer` | `string $address` | `void` | Exclude server |
| `includeServer` | `string $address` | `void` | Include server |
| `consistencyCheck` | — | `bool` | Check consistency |
| `getClusterStatus` | — | `array<string, mixed>` | Get cluster status |
| `forceRecovery` | `string $dcId` | `void` | Deprecated — throws `\LogicException` (unsupported by special keys) |
| `forceRecovery` | `string $dcId` | `void` | Force recovery into a datacenter via the C API — may cause data loss |
| `forceRecoveryWithDataLoss` | `string $dcId` | `void` | Same as `forceRecovery` (explicit name) — may cause data loss |
| `createSnapshot` | `string $uid, string $snapCommand` | `void` | Start a DR snapshot (`fdbcli snapshot` equivalent) |
181 changes: 149 additions & 32 deletions src/AdminClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
* | excludeServer | server address (IP:port) | `[A-Za-z0-9._:-]` | 256 bytes |
* | includeServer | server address (IP:port) | (same as excludeServer) | 256 bytes |
* | configure | whitespace-split tokens, ≥1 token | `[A-Za-z0-9_-]` per token | 64 bytes |
* | forceRecovery | datacenter identifier | `[A-Za-z0-9_-]` | 64 bytes |
* | forceRecoveryWithDataLoss | datacenter identifier | `[A-Za-z0-9_-]` | 64 bytes |
* | createSnapshot | snapshot UID | `[0-9a-fA-F]`, exactly 32 chars | 32 bytes |
* | createSnapshot | snapshot command | printable ASCII (0x20–0x7E) | 256 bytes |
*
* The allow-list deliberately excludes every byte below 0x20, 0x7F (DEL),
* 0x80–0xFF, and `"/"`, so a tenant name or address containing a slash,
Expand All @@ -42,13 +44,18 @@
*
* ## Unsupported operations
*
* `configure()` and `forceRecovery()` are deprecated and throw
* `configure()` is deprecated and throws
* `\LogicException` synchronously (after input validation): FoundationDB
* does not expose cluster configuration (redundancy mode, storage engine)
* or forced recovery through the special-key space — the corresponding
* through the special-key space — the corresponding
* writes fail at commit with `special_keys_no_module_found`. Use the
* `fdbcli` `configure` and `force_recovery_with_data_loss` commands
* `fdbcli` `configure` command
* instead. See issue #43.
*
* Forced recovery is implemented via the direct C API entry point
* `fdb_database_force_recovery_with_data_loss()` (issue #97), the same
* RPC the `fdbcli` `force_recovery_with_data_loss` command and the other
* bindings use; the old special-key write path is gone.
*/
final readonly class AdminClient
{
Expand All @@ -65,7 +72,7 @@
private const MAX_LABEL_LENGTH = 256;

/**
* Maximum byte length for a single configure() / forceRecovery() token.
* Maximum byte length for a single configure() / forceRecoveryWithDataLoss() token.
*/
private const MAX_TOKEN_LENGTH = 64;

Expand Down Expand Up @@ -389,43 +396,104 @@ private function decodeClusterStatusJson(string $json): array
}

/**
* Force database recovery (use with caution!).
* Force database recovery into the given datacenter, abandoning data
* written since the datacenter's last usable state.
*
* @deprecated NOT SUPPORTED by the FoundationDB special-key space and
* scheduled for removal. Forced recovery is performed by the
* cluster controller over an RPC
* (`IClusterConnectionRecord::forceRecovery`, exposed in
* `fdbcli` as `force_recovery_with_data_loss <dcid>`) — there
* is no `\xff\xff/management/force_recovery` special key, and
* a write to that key fails at commit with
* `special_keys_no_module_found`. Use the `fdbcli`
* `force_recovery_with_data_loss` command instead. This
* method now throws a `\LogicException` synchronously instead
* of failing opaquely at commit time.
*
* The dcId is still validated first ({@see self::validateToken()}) so a
* malformed identifier keeps failing with a precise
* `\InvalidArgumentException`.
* Implemented via the direct C API entry point
* `fdb_database_force_recovery_with_data_loss()` (issue #97) — the same
* RPC the cluster controller and `fdbcli`
* `force_recovery_with_data_loss <dcid>` use — not via a special-key
* write. The dcId is validated against {@see self::TOKEN_REGEX} before
* the FFI call.
*
* @param string $dcId Datacenter ID to recover into. Must match
* `[A-Za-z0-9_-]{1,64}`.
*
* @throws \InvalidArgumentException If `$dcId` is invalid.
* @throws \LogicException Always — see the deprecation note.
* @throws FDBException If the recovery fails.
*
* @warning This operation may cause data loss. Use only in emergency situations.
* @warning This operation may cause data loss. Use only in emergency
* situations.
*/
public function forceRecovery(string $dcId): never
public function forceRecovery(string $dcId): void
{
$this->validateToken($dcId, 'forceRecovery');
$this->forceRecoveryWithDataLoss($dcId);
}

throw new \LogicException(
'AdminClient::forceRecovery() is not supported: forced recovery is performed by the '
. 'cluster controller over an RPC and has no special-key representation — there is no '
. '\xff\xff/management/force_recovery key, and a write to it fails at commit with '
. 'special_keys_no_module_found. Use the fdbcli `force_recovery_with_data_loss` command '
. 'instead. See issue #43.',
/**
* Force database recovery into the given datacenter, abandoning data
* written since the datacenter's last usable state (direct C API).
*
* Binds `fdb_database_force_recovery_with_data_loss()`. This is the
* same entry point the `fdbcli` command
* `force_recovery_with_data_loss <dcid>` uses.
*
* @param string $dcId Datacenter ID to recover into. Must match
* `[A-Za-z0-9_-]{1,64}`.
*
* @throws \InvalidArgumentException If `$dcId` is invalid.
* @throws FDBException If the recovery fails.
*
* @warning Data loss is in the name of this call: everything not
* replicated into `$dcId` is abandoned permanently. Use only
* in emergency situations when the primary datacenter is gone.
*/
public function forceRecoveryWithDataLoss(string $dcId): void
{
$this->validateToken($dcId, 'forceRecoveryWithDataLoss');

$future = new Future\FutureVoid(
// @phpstan-ignore method.notFound
$this->client->fdb->fdb_database_force_recovery_with_data_loss(
$this->database->getDatabasePointer(),
$dcId,
strlen($dcId),
),
$this->client,
);

$future->await();
}

/**
* Start a disaster-recovery snapshot on the cluster (direct C API).
*
* Binds `fdb_database_create_snapshot()` — the entry point behind the
* `fdbcli` `snapshot` command. Requires snapshot support to be
* configured on the cluster; otherwise the future resolves to an
* error.
*
* @param string $uid 32-character hexadecimal snapshot UID,
* as issued by the DR tooling. Must match
* `[0-9a-fA-F]{32}`.
* @param string $snapCommand Snapshot command payload forwarded verbatim
* to the cluster (e.g. a start/abort
* instruction). Must be printable ASCII
* (0x20–0x7E), 1–256 bytes.
*
* @throws \InvalidArgumentException If `$uid` is not a 32-char hex UID
* or `$snapCommand` is not printable
* ASCII within the length bound.
* @throws FDBException If the snapshot request fails.
*/
public function createSnapshot(string $uid, string $snapCommand): void
{
$this->validateSnapshotUid($uid);
$this->validateSnapshotCommand($snapCommand);

$future = new Future\FutureVoid(
// @phpstan-ignore method.notFound
$this->client->fdb->fdb_database_create_snapshot(
$this->database->getDatabasePointer(),
$uid,
strlen($uid),
$snapCommand,
strlen($snapCommand),
),
$this->client,
);

$future->await();
}

// ----------------------------------------------------------------------
Expand Down Expand Up @@ -526,7 +594,7 @@ private function validateAddress(string $address): void
/**
* Validate a single whitespace-delimited token against the allow-list
* `^[A-Za-z0-9_-]{1,64}\z`. Used by `configure()` for both the
* redundancy and storage tokens, and by `forceRecovery()` for the dcId.
* redundancy and storage tokens, and by `forceRecoveryWithDataLoss()` for the dcId.
*
* @param string $value Token supplied by the caller.
* @param string $caller Calling method name, included in the
Expand Down Expand Up @@ -567,6 +635,55 @@ private function validateToken(string $value, string $caller): void
}
}

/**
* Validate a snapshot UID: exactly 32 hexadecimal characters, matching
* the UID format used by the FDB snapshot/DR tooling.
*
* @throws \InvalidArgumentException If the UID is not 32 hex characters.
*/
private function validateSnapshotUid(string $uid): void
{
if (preg_match('/\A[0-9a-fA-F]{32}\z/', $uid) !== 1) {
throw new \InvalidArgumentException(sprintf(
'createSnapshot: snapshot UID %s must be exactly 32 hexadecimal characters ([0-9a-fA-F])',
$this->printableLabel($uid),
));
}
}

/**
* Validate a snapshot command payload: printable ASCII (0x20–0x7E),
* 1–{@see self::MAX_LABEL_LENGTH} bytes.
*
* @throws \InvalidArgumentException If the command is empty, exceeds the
* byte-length bound, or contains a
* non-printable byte.
*/
private function validateSnapshotCommand(string $command): void
{
if ($command === '') {
throw new \InvalidArgumentException('createSnapshot: snapshot command must not be empty');
}

if (strlen($command) > self::MAX_LABEL_LENGTH) {
throw new \InvalidArgumentException(sprintf(
'createSnapshot: snapshot command exceeds maximum length %d bytes (got %d bytes): %s',
self::MAX_LABEL_LENGTH,
strlen($command),
$this->printableLabel($command),
));
}

if (preg_match('/\A[\x20-\x7E]+\z/', $command) !== 1) {
throw new \InvalidArgumentException(sprintf(
'createSnapshot: snapshot command %s contains a non-printable byte; '
. 'allowed: printable ASCII 0x20-0x7E (1-%d bytes)',
$this->printableLabel($command),
self::MAX_LABEL_LENGTH,
));
}
}

/**
* Parse and validate a `configure()` argument.
*
Expand Down
7 changes: 7 additions & 0 deletions src/NativeClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ final class NativeClient
FDBFuture* fdb_database_get_server_protocol(FDBDatabase* d, uint64_t expected_version);
fdb_error_t fdb_database_set_option(FDBDatabase* d, int option, const void* value, int value_length);
fdb_error_t fdb_database_create_transaction(FDBDatabase* d, FDBTransaction** out_transaction);
FDBFuture* fdb_database_create_snapshot(
FDBDatabase* d, const char* uid, int uid_length,
const char* snap_command, int snap_command_length
);
FDBFuture* fdb_database_force_recovery_with_data_loss(
FDBDatabase* d, const char* dcid, int dcid_length
);
FDBFuture* fdb_database_reboot_worker(
FDBDatabase* d, const char* address, int address_length, fdb_bool_t check, int duration
);
Expand Down
Loading
Loading