Verify: index files several at a time over SFTP, pack folders listed, progress stored and shown
This commit is contained in:
+143
-14
@@ -28,6 +28,8 @@ final class SftpReadStream {
|
||||
private const PROTOCOL = 'ngbsftpread';
|
||||
/** Requests in flight once a stream reads sequentially; it starts at one and doubles. */
|
||||
public const DEPTH = 8;
|
||||
/** Files read at once by readMany(): each holds a handle on the server while its batch runs. */
|
||||
public const BATCH = 32;
|
||||
/** OpenSSH and the Hetzner Storage Box both answer at most 255 KiB per READ. */
|
||||
private const CHUNK = 261120;
|
||||
/** Every SFTP server must handle packets of this size (draft-ietf-secsh-filexfer-02, section 3). */
|
||||
@@ -101,20 +103,8 @@ final class SftpReadStream {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (!is_callable([$storage, 'getConnection']) || !is_callable([$storage, 'getRoot'])) {
|
||||
return null;
|
||||
}
|
||||
$sftp = $storage->getConnection();
|
||||
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
|
||||
// methods of these names neither public nor at all.
|
||||
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|
||||
|| !is_callable([$sftp, '_realpath']) || !is_callable([$sftp, 'pwd'])
|
||||
|| !array_key_exists('packet_type', get_object_vars($sftp))) {
|
||||
return null;
|
||||
}
|
||||
// phpseclib opens the SFTP channel on its first SFTP call, not at login: on a fresh
|
||||
// connection a packet sent directly would go nowhere. pwd() opens it like any call does.
|
||||
if ($sftp->pwd() === false) {
|
||||
$sftp = self::connection($storage);
|
||||
if ($sftp === null) {
|
||||
return null;
|
||||
}
|
||||
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
|
||||
@@ -140,6 +130,145 @@ final class SftpReadStream {
|
||||
return $fh;
|
||||
}
|
||||
|
||||
/**
|
||||
* The storage's phpseclib connection, with its SFTP channel open, when it offers what these
|
||||
* streams need; null otherwise.
|
||||
*/
|
||||
private static function connection(object $storage): ?object {
|
||||
if (!is_callable([$storage, 'getConnection']) || !is_callable([$storage, 'getRoot'])) {
|
||||
return null;
|
||||
}
|
||||
$sftp = $storage->getConnection();
|
||||
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
|
||||
// methods of these names neither public nor at all.
|
||||
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|
||||
|| !is_callable([$sftp, '_realpath']) || !is_callable([$sftp, 'pwd'])
|
||||
|| !array_key_exists('packet_type', get_object_vars($sftp))) {
|
||||
return null;
|
||||
}
|
||||
// phpseclib opens the SFTP channel on its first SFTP call, not at login: on a fresh
|
||||
// connection a packet sent directly would go nowhere. pwd() opens it like any call does.
|
||||
if ($sftp->pwd() === false) {
|
||||
return null;
|
||||
}
|
||||
return $sftp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read many small files whole, BATCH at a time: the OPEN, READ and CLOSE requests of a batch
|
||||
* all go out before their answers are read, so a batch costs a few round trips instead of
|
||||
* four per file. Meant for files of up to a few hundred KiB (the repository's index files):
|
||||
* every file is held in memory.
|
||||
*
|
||||
* @param list<string> $paths below the storage's root
|
||||
* @return array<string, string|null>|null contents by path; null for a file this could not
|
||||
* read (missing, or a server error), so the caller
|
||||
* reads it the usual way; null for all when the
|
||||
* connection does not offer what this needs
|
||||
* @throws BackendException when the connection failed while requests were out: the storage's
|
||||
* own stream must not be tried on it
|
||||
*/
|
||||
public static function readMany(object $storage, array $paths): ?array {
|
||||
if (self::$failures >= self::MAX_FAILURES) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$sftp = self::connection($storage);
|
||||
$root = $sftp === null ? '' : rtrim((string)$storage->getRoot(), '/');
|
||||
} catch (\Throwable) {
|
||||
self::$failures++;
|
||||
return null;
|
||||
}
|
||||
if ($sftp === null) {
|
||||
return null;
|
||||
}
|
||||
$result = [];
|
||||
foreach (array_chunk($paths, self::BATCH) as $batch) {
|
||||
try {
|
||||
$result += self::readBatch($sftp, $root, $batch);
|
||||
} catch (\Throwable $e) {
|
||||
self::$failures++;
|
||||
throw $e instanceof BackendException ? $e : new BackendException('SFTP connection failed while reading ' . count($batch) . ' files: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
self::$failures = 0;
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Requests of one kind for a whole batch, then their answers in order. @return list<array{int, string|false}> */
|
||||
private static function exchange(object $sftp, int $type, array $packets): array {
|
||||
$ids = [];
|
||||
foreach ($packets as $packet) {
|
||||
$id = self::nextId();
|
||||
if (!$sftp->_send_sftp_packet($type, $packet, $id)) {
|
||||
// Requests already sent cannot be taken back: the connection is unusable now.
|
||||
throw new BackendException('SFTP connection failed while sending a request');
|
||||
}
|
||||
$ids[] = $id;
|
||||
}
|
||||
$answers = [];
|
||||
foreach ($ids as $id) {
|
||||
$response = $sftp->_get_sftp_packet($id);
|
||||
if (!is_string($response)) {
|
||||
throw new BackendException('SFTP connection failed while waiting for an answer');
|
||||
}
|
||||
$answers[] = [$sftp->packet_type, $response];
|
||||
}
|
||||
return $answers;
|
||||
}
|
||||
|
||||
/** @param list<string> $batch @return array<string, string|null> */
|
||||
private static function readBatch(object $sftp, string $root, array $batch): array {
|
||||
$result = array_fill_keys($batch, null);
|
||||
$opens = [];
|
||||
foreach ($batch as $path) {
|
||||
$remote = $sftp->_realpath($root . '/' . ltrim($path, '/'));
|
||||
if (!is_string($remote)) {
|
||||
throw new BackendException('SFTP path could not be resolved: ' . $path);
|
||||
}
|
||||
$opens[] = pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0);
|
||||
}
|
||||
$handles = []; // path => handle
|
||||
foreach (self::exchange($sftp, self::FXP_OPEN, $opens) as $i => [$type, $response]) {
|
||||
if ($type === self::FXP_HANDLE) {
|
||||
$handles[$batch[$i]] = substr($response, 4);
|
||||
}
|
||||
// any other answer: not readable this way, the caller reads it the usual way
|
||||
}
|
||||
$data = array_fill_keys(array_keys($handles), '');
|
||||
$reading = $handles;
|
||||
while ($reading !== []) {
|
||||
$length = self::chunk($sftp);
|
||||
$reads = [];
|
||||
foreach ($reading as $path => $handle) {
|
||||
$offset = strlen($data[$path]);
|
||||
$reads[] = pack('Na*N3', strlen($handle), $handle, intdiv($offset, 4294967296), $offset % 4294967296, $length);
|
||||
}
|
||||
$paths = array_keys($reading);
|
||||
foreach (self::exchange($sftp, self::FXP_READ, $reads) as $i => [$type, $response]) {
|
||||
$path = $paths[$i];
|
||||
if ($type === self::FXP_DATA && strlen($response) > 4) {
|
||||
$data[$path] .= substr($response, 4); // more may follow: read on from there
|
||||
continue;
|
||||
}
|
||||
unset($reading[$path]);
|
||||
$status = $type === self::FXP_STATUS ? unpack('N', $response) : false;
|
||||
if (!is_array($status) || ($status[1] ?? null) !== self::FX_EOF) {
|
||||
unset($data[$path]); // a server error on this file: read it the usual way
|
||||
}
|
||||
}
|
||||
}
|
||||
$closes = [];
|
||||
foreach ($handles as $handle) {
|
||||
$closes[] = pack('Na*', strlen($handle), $handle);
|
||||
}
|
||||
self::exchange($sftp, self::FXP_CLOSE, $closes);
|
||||
foreach ($data as $path => $contents) {
|
||||
$result[$path] = $contents;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** Test hook: forget the learned READ sizes and earlier failures. */
|
||||
public static function reset(): void {
|
||||
self::$chunks = null;
|
||||
|
||||
Reference in New Issue
Block a user