From c5c6829b73d8a7d017fdea893a056a92309d32c8 Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Fri, 11 Sep 2026 11:39:44 +0800
Subject: [PATCH 1/8] Add Pipe logical backup sink and restore tooling
---
distribution/src/assembly/cli.xml | 2 +
iotdb-client/cli/pom.xml | 5 +
.../apache/iotdb/cli/i18n/CliMessages.java | 57 +
.../apache/iotdb/cli/i18n/CliMessages.java | 57 +
.../tool/pipe/PipeLogicalBackupTool.java | 817 ++++++++++++
.../tool/pipe/PipeLogicalBackupToolTest.java | 166 +++
.../iotdb/db/i18n/DataNodePipeMessages.java | 11 +
.../iotdb/db/i18n/DataNodePipeMessages.java | 11 +
.../PipeDataRegionSinkConstructor.java | 7 +
.../PipeSchemaRegionSinkConstructor.java | 7 +
.../logicalbackup/LogicalBackupSink.java | 591 +++++++++
iotdb-core/node-commons/pom.xml | 4 +
.../commons/i18n/LogicalBackupMessages.java | 151 +++
.../commons/i18n/LogicalBackupMessages.java | 151 +++
.../plugin/builtin/BuiltinPipePlugin.java | 4 +
.../sink/logicalbackup/LogicalBackupSink.java | 24 +
.../config/constant/PipeSinkConstant.java | 49 +
.../LogicalBackupArchiveReader.java | 481 +++++++
.../logicalbackup/LogicalBackupFormat.java | 62 +
.../logicalbackup/LogicalBackupManifest.java | 68 +
.../logicalbackup/LogicalBackupRecord.java | 136 ++
.../LogicalBackupRecordType.java | 54 +
.../LogicalBackupSegmentReader.java | 541 ++++++++
.../logicalbackup/LogicalBackupWriter.java | 1162 +++++++++++++++++
.../LogicalBackupArchiveReaderTest.java | 272 ++++
.../LogicalBackupWriterTest.java | 588 +++++++++
scripts/tools/export-pipe-logical-backup.sh | 21 +
scripts/tools/import-pipe-logical-backup.sh | 21 +
scripts/tools/pipe-logical-backup.sh | 43 +
.../windows/export-pipe-logical-backup.bat | 21 +
.../windows/import-pipe-logical-backup.bat | 21 +
scripts/tools/windows/pipe-logical-backup.bat | 40 +
32 files changed, 5645 insertions(+)
create mode 100644 iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
create mode 100644 iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java
create mode 100644 iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
create mode 100644 iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/LogicalBackupMessages.java
create mode 100644 iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/LogicalBackupMessages.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/sink/logicalbackup/LogicalBackupSink.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReader.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupFormat.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupManifest.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupRecord.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupRecordType.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupSegmentReader.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriter.java
create mode 100644 iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReaderTest.java
create mode 100644 iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriterTest.java
create mode 100644 scripts/tools/export-pipe-logical-backup.sh
create mode 100644 scripts/tools/import-pipe-logical-backup.sh
create mode 100644 scripts/tools/pipe-logical-backup.sh
create mode 100644 scripts/tools/windows/export-pipe-logical-backup.bat
create mode 100644 scripts/tools/windows/import-pipe-logical-backup.bat
create mode 100644 scripts/tools/windows/pipe-logical-backup.bat
diff --git a/distribution/src/assembly/cli.xml b/distribution/src/assembly/cli.xml
index 6f4eed7f7c25..7e60322b4987 100644
--- a/distribution/src/assembly/cli.xml
+++ b/distribution/src/assembly/cli.xml
@@ -54,9 +54,11 @@
*data.*
*schema.*
*tsfile.*
+ *logical-backup*
**/*data.*
**/*schema.*
**/*tsfile.*
+ **/*logical-backup*
0755
diff --git a/iotdb-client/cli/pom.xml b/iotdb-client/cli/pom.xml
index 5de247a084a0..c9824ef4aec1 100644
--- a/iotdb-client/cli/pom.xml
+++ b/iotdb-client/cli/pom.xml
@@ -124,6 +124,10 @@
commons-cli
commons-cli
+
+ com.google.code.gson
+ gson
+
org.jline
jline
@@ -204,6 +208,7 @@
org.apache.iotdb:iotdb-server
org/apache/iotdb/db/utils/*
+ org/apache/iotdb/db/pipe/sink/payload/evolvable/request/PipeTransferDataNodeHandshakeV2Req.class
diff --git a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
index 0329812f2d4d..77888e65768a 100644
--- a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -96,4 +96,61 @@ private CliMessages() {}
public static final String LOG_INPUT_TIME_FORMAT_ARG_NOT_SUPPORTED_00172A7B = "Input time format {} is not supported, ";
public static final String LOG_PLEASE_INPUT_LIKE_YYYY_MM_DD_HH_MM_SS_SSS_9318BFC7 = "please input like yyyy-MM-dd\\ HH:mm:ss.SSS or yyyy-MM-dd'T'HH:mm:ss.SSS%n";
+ // Pipe logical backup tool
+ public static final String EXCEPTION_LOGICAL_BACKUP_COMMAND_FAILED_ARG_9973B0C0 =
+ "Logical backup command failed: %s";
+ public static final String EXCEPTION_UNKNOWN_LOGICAL_BACKUP_COMMAND_ARG_79275619 =
+ "Unknown logical backup command: %s";
+ public static final String EXCEPTION_OUTPUT_IS_REQUIRED_FOR_LOGICAL_BACKUP_EXPORT_603340B1 =
+ "--output is required for logical backup export";
+ public static final String LOG_STREAM_ARG_ARG_RECORDS_ARG_COMMITTED_EVENT_GROUPS_DE6BBAD0 =
+ "Stream %s: %d records, %d committed event groups";
+ public static final String
+ LOG_LOGICAL_BACKUP_VERIFIED_ARG_STREAMS_ARG_RECORDS_ARG_COMMITTED_EVENT_GROUPS_5210360B =
+ "Logical backup verified: %d streams, %d records, %d committed event groups";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EXPORT_TARGET_MUST_NOT_BE_INSIDE_SOURCE_A499F994 =
+ "Logical backup export target must not be inside source";
+ public static final String LOG_LOGICAL_BACKUP_EXPORTED_FROM_ARG_TO_ARG_B3E8D280 =
+ "Logical backup exported from %s to %s";
+ public static final String LOG_DRY_RUN_COMPLETED_NO_DATA_WAS_WRITTEN_38AE244B =
+ "Dry run completed; no data was written";
+ public static final String
+ LOG_LOGICAL_BACKUP_IMPORT_COMPLETED_ARG_EVENT_GROUPS_CHECKPOINT_ARG_16F6A72D =
+ "Logical backup import completed: %d event groups, checkpoint %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_IS_REQUIRED_1E7449AA =
+ "Logical backup option --%s is required";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_PASSWORD_ENVIRONMENT_VARIABLE_IS_NOT_SET_616738A2 =
+ "Logical backup password environment variable is not set";
+ public static final String EXCEPTION_LOGICAL_BACKUP_HANDSHAKE_FAILED_ARG_7CDD4697 =
+ "Logical backup handshake failed: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_TYPE_ARG_FAILED_ARG_75EE2D11 =
+ "Logical backup request type %d failed: %s";
+ public static final String
+ LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_4677380E =
+ "Use --input to specify the input. Export also requires --output; import requires --host and --port. Use --password-stdin or --password-env to avoid command-line passwords.";
+ public static final String
+ LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_RESTORE_STATS_BFF9FDC2 =
+ "pipe-logical-backup ";
+ public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_ENTRY_IS_UNSAFE_ARG_3E548152 =
+ "Logical backup archive entry is unsafe: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_EXCEEDS_SAFETY_LIMIT_FFC54432 =
+ "Logical backup archive exceeds safety limit";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EXPORT_SOURCE_MUST_BE_A_DIRECTORY_OR_MANIFEST_ARG_0BBDE9F0 =
+ "Logical backup export source must be a directory or manifest: %s";
+ public static final String
+ EXCEPTION_SPECIFY_EXACTLY_ONE_OF_PASSWORD_STDIN_AND_PASSWORD_ENV_FOR_LOGICAL_BACKUP_IMPORT_A96813D9 =
+ "Specify exactly one of --password-stdin and --password-env for logical backup import";
+ public static final String EXCEPTION_NO_PASSWORD_WAS_READ_FROM_STANDARD_INPUT_6294AB8E =
+ "No password was read from standard input";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_CHECKPOINT_DOES_NOT_MATCH_THE_SOURCE_OR_TARGET_ARG_B978184D =
+ "Logical backup checkpoint does not match the source or target: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_CHECKPOINT_IS_INVALID_ARG_71E82F4C =
+ "Logical backup checkpoint is invalid: %s";
+ public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_EXPORT_FORMAT_ARG_A6D7DEB1 =
+ "Unsupported logical backup export format: %s";
+
}
diff --git a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
index 61a14d94d588..530b504b29ce 100644
--- a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -92,4 +92,61 @@ private CliMessages() {}
public static final String LOG_INPUT_TIME_FORMAT_ARG_NOT_SUPPORTED_00172A7B = "不支持输入时间格式 {},";
public static final String LOG_PLEASE_INPUT_LIKE_YYYY_MM_DD_HH_MM_SS_SSS_9318BFC7 = "请输入类似 yyyy-MM-dd\\ HH:mm:ss.SSS 或 yyyy-MM-dd'T'HH:mm:ss.SSS 的格式%n";
+ // Pipe 逻辑备份工具
+ public static final String EXCEPTION_LOGICAL_BACKUP_COMMAND_FAILED_ARG_9973B0C0 =
+ "逻辑备份命令执行失败:%s";
+ public static final String EXCEPTION_UNKNOWN_LOGICAL_BACKUP_COMMAND_ARG_79275619 =
+ "未知的逻辑备份命令:%s";
+ public static final String EXCEPTION_OUTPUT_IS_REQUIRED_FOR_LOGICAL_BACKUP_EXPORT_603340B1 =
+ "逻辑备份导出必须指定 --output";
+ public static final String LOG_STREAM_ARG_ARG_RECORDS_ARG_COMMITTED_EVENT_GROUPS_DE6BBAD0 =
+ "流 %s:%d 条记录,%d 个已提交事件组";
+ public static final String
+ LOG_LOGICAL_BACKUP_VERIFIED_ARG_STREAMS_ARG_RECORDS_ARG_COMMITTED_EVENT_GROUPS_5210360B =
+ "逻辑备份校验通过:%d 个流,%d 条记录,%d 个已提交事件组";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EXPORT_TARGET_MUST_NOT_BE_INSIDE_SOURCE_A499F994 =
+ "逻辑备份导出目标不能位于源目录内";
+ public static final String LOG_LOGICAL_BACKUP_EXPORTED_FROM_ARG_TO_ARG_B3E8D280 =
+ "逻辑备份已从 %s 导出到 %s";
+ public static final String LOG_DRY_RUN_COMPLETED_NO_DATA_WAS_WRITTEN_38AE244B =
+ "试运行完成,未写入数据";
+ public static final String
+ LOG_LOGICAL_BACKUP_IMPORT_COMPLETED_ARG_EVENT_GROUPS_CHECKPOINT_ARG_16F6A72D =
+ "逻辑备份导入完成:%d 个事件组,checkpoint 为 %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_IS_REQUIRED_1E7449AA =
+ "逻辑备份选项 --%s 为必填项";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_PASSWORD_ENVIRONMENT_VARIABLE_IS_NOT_SET_616738A2 =
+ "未设置逻辑备份密码环境变量";
+ public static final String EXCEPTION_LOGICAL_BACKUP_HANDSHAKE_FAILED_ARG_7CDD4697 =
+ "逻辑备份握手失败:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_TYPE_ARG_FAILED_ARG_75EE2D11 =
+ "逻辑备份请求类型 %d 执行失败:%s";
+ public static final String
+ LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_4677380E =
+ "使用 --input 指定输入。导出还需要 --output;导入需要 --host 和 --port。请使用 --password-stdin 或 --password-env,避免密码出现在命令行中。";
+ public static final String
+ LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_RESTORE_STATS_BFF9FDC2 =
+ "pipe-logical-backup ";
+ public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_ENTRY_IS_UNSAFE_ARG_3E548152 =
+ "逻辑备份归档项路径不安全:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_EXCEEDS_SAFETY_LIMIT_FFC54432 =
+ "逻辑备份归档超过安全限制";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EXPORT_SOURCE_MUST_BE_A_DIRECTORY_OR_MANIFEST_ARG_0BBDE9F0 =
+ "逻辑备份导出源必须是目录或 manifest:%s";
+ public static final String
+ EXCEPTION_SPECIFY_EXACTLY_ONE_OF_PASSWORD_STDIN_AND_PASSWORD_ENV_FOR_LOGICAL_BACKUP_IMPORT_A96813D9 =
+ "逻辑备份导入必须且只能指定 --password-stdin 或 --password-env 中的一项";
+ public static final String EXCEPTION_NO_PASSWORD_WAS_READ_FROM_STANDARD_INPUT_6294AB8E =
+ "未能从标准输入读取密码";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_CHECKPOINT_DOES_NOT_MATCH_THE_SOURCE_OR_TARGET_ARG_B978184D =
+ "逻辑备份 checkpoint 与源备份或目标实例不匹配:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_CHECKPOINT_IS_INVALID_ARG_71E82F4C =
+ "逻辑备份 checkpoint 无效:%s";
+ public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_EXPORT_FORMAT_ARG_A6D7DEB1 =
+ "不支持的逻辑备份导出格式:%s";
+
}
diff --git a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
new file mode 100644
index 000000000000..5d80243f3adf
--- /dev/null
+++ b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
@@ -0,0 +1,817 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.tool.pipe;
+
+import org.apache.iotdb.cli.i18n.CliMessages;
+import org.apache.iotdb.commons.client.property.ThriftClientProperty;
+import org.apache.iotdb.commons.pipe.sink.client.IoTDBSyncClient;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader.BackupStream;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader.EventGroup;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupFormat;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupManifest;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupRecord;
+import org.apache.iotdb.commons.pipe.sink.payload.thrift.common.PipeTransferHandshakeConstant;
+import org.apache.iotdb.db.pipe.sink.payload.evolvable.request.PipeTransferDataNodeHandshakeV2Req;
+import org.apache.iotdb.rpc.TSStatusCode;
+import org.apache.iotdb.service.rpc.thrift.TPipeTransferReq;
+import org.apache.iotdb.service.rpc.thrift.TPipeTransferResp;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonParseException;
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+import org.apache.thrift.TException;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.PrintWriter;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+/** Command line inspector, verifier, archiver and importer for Pipe logical backups. */
+public final class PipeLogicalBackupTool {
+
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+ private static final String COMMAND_INSPECT = "inspect";
+ private static final String COMMAND_VERIFY = "verify";
+ private static final String COMMAND_EXPORT = "export";
+ private static final String COMMAND_IMPORT = "import";
+ private static final String COMMAND_RESTORE = "restore";
+ private static final String COMMAND_STATS = "stats";
+ private static final String OPTION_INPUT = "input";
+ private static final String OPTION_OUTPUT = "output";
+ private static final String OPTION_RESUME = "resume";
+ private static final String OPTION_FORMAT = "format";
+ private static final String OPTION_DEEP = "deep";
+ // Compatibility aliases for the initial command-line prototype.
+ private static final String OPTION_SOURCE = "source";
+ private static final String OPTION_TARGET = "target";
+ private static final String OPTION_HOST = "host";
+ private static final String OPTION_PORT = "port";
+ private static final String OPTION_USER = "user";
+ private static final String OPTION_PASSWORD_STDIN = "password-stdin";
+ private static final String OPTION_PASSWORD_ENV = "password-env";
+ private static final String OPTION_CHECKPOINT = "checkpoint";
+ private static final String OPTION_DRY_RUN = "dry-run";
+ private static final String OPTION_ALLOW_INCOMPLETE = "allow-incomplete";
+ private static final String OPTION_JSON = "json";
+ private static final int MAX_ARCHIVE_ENTRIES = 1_000_000;
+ private static final long MAX_ARCHIVE_BYTES = 4L * 1024 * 1024 * 1024 * 1024;
+ private static final String CHECKPOINT_FORMAT_NAME =
+ "iotdb-pipe-logical-backup-import-checkpoint";
+ private static final String CHECKPOINT_FORMAT_VERSION = "1.0";
+
+ private PipeLogicalBackupTool() {}
+
+ public static void main(final String[] args) {
+ int exitCode = 0;
+ try {
+ exitCode = run(args);
+ } catch (final Exception e) {
+ System.err.println(
+ String.format(
+ CliMessages.EXCEPTION_LOGICAL_BACKUP_COMMAND_FAILED_ARG_9973B0C0, e.getMessage()));
+ exitCode = 1;
+ }
+ if (exitCode != 0) {
+ System.exit(exitCode);
+ }
+ }
+
+ static int run(final String[] args) throws Exception {
+ if (args.length == 0 || "--help".equals(args[0]) || "-h".equals(args[0])) {
+ printUsage();
+ return 0;
+ }
+ final String command = args[0].toLowerCase(java.util.Locale.ROOT);
+ final CommandLine line = parse(command, java.util.Arrays.copyOfRange(args, 1, args.length));
+ switch (command) {
+ case COMMAND_INSPECT:
+ return inspect(line);
+ case COMMAND_VERIFY:
+ return verify(line);
+ case COMMAND_EXPORT:
+ return export(line);
+ case COMMAND_IMPORT:
+ case COMMAND_RESTORE:
+ return importBackup(line);
+ case COMMAND_STATS:
+ return inspect(line);
+ default:
+ throw new ParseException(
+ String.format(
+ CliMessages.EXCEPTION_UNKNOWN_LOGICAL_BACKUP_COMMAND_ARG_79275619, command));
+ }
+ }
+
+ private static CommandLine parse(final String command, final String[] args)
+ throws ParseException {
+ final Options options = new Options();
+ addOption(options, OPTION_INPUT, true, false);
+ addOption(options, OPTION_OUTPUT, true, false);
+ addOption(options, OPTION_RESUME, true, false);
+ addOption(options, OPTION_FORMAT, true, false);
+ addOption(options, OPTION_DEEP, false, false);
+ addOption(options, OPTION_SOURCE, true, false);
+ addOption(options, OPTION_TARGET, true, false);
+ addOption(options, OPTION_HOST, true, false);
+ addOption(options, OPTION_PORT, true, false);
+ addOption(options, OPTION_USER, true, false);
+ addOption(options, OPTION_PASSWORD_STDIN, false, false);
+ addOption(options, OPTION_PASSWORD_ENV, true, false);
+ addOption(options, OPTION_CHECKPOINT, true, false);
+ addOption(options, OPTION_DRY_RUN, false, false);
+ addOption(options, OPTION_ALLOW_INCOMPLETE, false, false);
+ addOption(options, OPTION_JSON, false, false);
+ final CommandLine line = new DefaultParser().parse(options, args);
+ final String input = optionValue(line, OPTION_INPUT, OPTION_SOURCE);
+ if (input == null || input.isBlank()) {
+ throw new ParseException(
+ String.format(
+ CliMessages.EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_IS_REQUIRED_1E7449AA, OPTION_INPUT));
+ }
+ final String output = optionValue(line, OPTION_OUTPUT, OPTION_TARGET);
+ if (COMMAND_EXPORT.equals(command) && (output == null || output.isBlank())) {
+ throw new ParseException(
+ CliMessages.EXCEPTION_OUTPUT_IS_REQUIRED_FOR_LOGICAL_BACKUP_EXPORT_603340B1);
+ }
+ final String format = line.getOptionValue(OPTION_FORMAT, "binary");
+ if (COMMAND_EXPORT.equals(command) && !"binary".equalsIgnoreCase(format)) {
+ throw new ParseException(
+ String.format(
+ CliMessages.EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_EXPORT_FORMAT_ARG_A6D7DEB1, format));
+ }
+ return line;
+ }
+
+ private static void addOption(
+ final Options options, final String name, final boolean hasArg, final boolean required) {
+ options.addOption(Option.builder().longOpt(name).hasArg(hasArg).required(required).build());
+ }
+
+ private static int inspect(final CommandLine line) throws IOException {
+ final List streams = read(line);
+ if (line.hasOption(OPTION_JSON)) {
+ final List
+
+ com.google.code.gson
+ gson
+
com.google.code.findbugs
jsr305
diff --git a/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/LogicalBackupMessages.java b/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/LogicalBackupMessages.java
new file mode 100644
index 000000000000..1629e2dd0002
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/i18n/en/org/apache/iotdb/commons/i18n/LogicalBackupMessages.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.i18n;
+
+public final class LogicalBackupMessages {
+
+ public static final String EXCEPTION_DIRECTORY_5F8F22B8 = "directory";
+ public static final String EXCEPTION_MANIFEST_7F5CB74A = "manifest";
+ public static final String EXCEPTION_FSYNC_POLICY_6D493614 = "fsyncPolicy";
+ public static final String EXCEPTION_EVENT_GROUP_ID_C6F6268A = "eventGroupId";
+ public static final String EXCEPTION_LOGICAL_BACKUP_DIRECTORY_ALREADY_EXISTS_ARG_1C521D1D =
+ "Logical backup directory already exists: %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EVENT_MUST_CONTAIN_AT_LEAST_ONE_REQUEST_0C278BAC =
+ "Logical backup event must contain at least one request";
+ public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_BODY_MUST_NOT_BE_NULL_EFFD92D9 =
+ "Logical backup request body must not be null";
+ public static final String EXCEPTION_LOGICAL_BACKUP_RECORD_EXCEEDS_MAX_RECORD_BYTES_B9A9C996 =
+ "Logical backup record exceeds max-record-bytes";
+ public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_MANIFEST_FORMAT_ARG_5005F99E =
+ "Unsupported logical backup manifest format: %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_MANIFEST_DOES_NOT_MATCH_ARG_EXPECTED_ARG_FOUND_ARG_D7BC8AD1 =
+ "Logical backup manifest does not match %s: expected %s, found %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_DIRECTORY_IS_LOCKED_ARG_A4366800 =
+ "Logical backup directory is locked: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_WRITER_IS_CLOSED_EE463BBB =
+ "Logical backup writer is closed";
+ public static final String EXCEPTION_INCOMPLETE_LOGICAL_BACKUP_SEGMENT_HEADER_ARG_1FE8371A =
+ "Incomplete logical backup segment header: %s";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_FOOTER_SIZE_ARG_4711E278 =
+ "Invalid logical backup segment footer size: %s";
+ public static final String
+ EXCEPTION_INVALID_LOGICAL_BACKUP_RECORD_MAGIC_AT_OFFSET_ARG_IN_ARG_C132B9D5 =
+ "Invalid logical backup record magic at offset %d in %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_RECORD_HEADER_CRC_MISMATCH_AT_OFFSET_ARG_IN_ARG_7076DD79 =
+ "Logical backup record header CRC mismatch at offset %d in %s";
+ public static final String
+ EXCEPTION_INVALID_LOGICAL_BACKUP_RECORD_LENGTH_AT_OFFSET_ARG_IN_ARG_A7F4048E =
+ "Invalid logical backup record length at offset %d in %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_RECORD_PAYLOAD_CRC_MISMATCH_AT_OFFSET_ARG_IN_ARG_F9436552 =
+ "Logical backup record payload CRC mismatch at offset %d in %s";
+ public static final String EXCEPTION_UNKNOWN_LOGICAL_BACKUP_RECORD_TYPE_ARG_IN_ARG_22BB599D =
+ "Unknown logical backup record type %d in %s";
+ public static final String EXCEPTION_NESTED_LOGICAL_BACKUP_EVENT_GROUPS_IN_ARG_896AC47A =
+ "Nested logical backup event groups in %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_COMMIT_WITHOUT_BEGIN_IN_ARG_B9031541 =
+ "Logical backup commit without begin in %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_OPERATION_INDEX_MISMATCH_AT_OFFSET_ARG_IN_ARG_30C3005F =
+ "Logical backup operation index mismatch at offset %d in %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEQUENCE_MISMATCH_AT_OFFSET_ARG_IN_ARG_CCEC352B =
+ "Logical backup sequence mismatch at offset %d in %s";
+ public static final String EXCEPTION_INCOMPLETE_LOGICAL_BACKUP_RECORD_TAIL_ARG_F722DEE7 =
+ "Incomplete logical backup record tail: %s";
+ public static final String
+ EXCEPTION_SEALED_LOGICAL_BACKUP_SEGMENT_CONTAINS_AN_OPEN_EVENT_GROUP_ARG_54C274D8 =
+ "Sealed logical backup segment contains an open event group: %s";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_MAGIC_ARG_8340BA73 =
+ "Invalid logical backup segment magic: %s";
+ public static final String
+ EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_SEGMENT_MAJOR_VERSION_ARG_ARG_CE07F1CB =
+ "Unsupported logical backup segment major version %d: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_HEADER_CRC_MISMATCH_ARG_07466D55 =
+ "Logical backup segment header CRC mismatch: %s";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_FOOTER_MAGIC_ARG_D973E2B4 =
+ "Invalid logical backup segment footer magic: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_ID_MISMATCH_ARG_D832F14D =
+ "Logical backup segment footer ID mismatch: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_DIGEST_MISMATCH_ARG_85346116 =
+ "Logical backup segment digest mismatch: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_CRC_MISMATCH_ARG_25FB7D91 =
+ "Logical backup segment footer CRC mismatch: %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_METADATA_MISMATCH_ARG_77D1FADB =
+ "Logical backup segment footer metadata mismatch: %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EVENT_ID_ARG_WAS_ALREADY_WRITTEN_WITH_A_DIFFERENT_DIGEST_6A297330 =
+ "Logical backup event ID %s was already written with a different digest";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_WRITER_CONFIGURATION_707BCC99 =
+ "Invalid logical backup writer configuration: segment-size, max-record, fsync-batch and"
+ + " fsync-period must be positive";
+ public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_OUTSIDE_EVENT_GROUP_8351818F =
+ "Logical backup request outside an event group at offset %d in %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_CONTROL_RECORD_INSIDE_EVENT_GROUP_C45D158B =
+ "Logical backup control record inside an event group at offset %d in %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_ID_MISMATCH_ARG_9FE7E88A =
+ "Logical backup segment ID mismatch: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEQUENCE_GAP_IN_ARG_D8698149 =
+ "Logical backup sequence gap in %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_HAS_NO_SEGMENTS_B48D5F15 =
+ "Logical backup has no segments";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_PATH_ARG_6485A845 =
+ "Invalid logical backup segment path: %s";
+ public static final String EXCEPTION_NO_LOGICAL_BACKUP_MANIFEST_FOUND_UNDER_ARG_4ACEA70E =
+ "No logical backup manifest found under %s";
+ public static final String EXCEPTION_DUPLICATE_LOGICAL_BACKUP_STREAM_ID_ARG_1BF0F6EE =
+ "Duplicate logical backup stream ID: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_MANIFEST_HAS_NO_SEGMENTS_ARG_AFB5C138 =
+ "Logical backup manifest has no segments: %s";
+ public static final String EXCEPTION_UNLISTED_LOGICAL_BACKUP_SEGMENT_ARG_FF548C0B =
+ "Unlisted logical backup segment %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_METADATA_MISMATCH_ARG_376FD0B3 =
+ "Logical backup segment metadata mismatch: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_IS_NOT_SEALED_ARG_937793FF =
+ "Logical backup segment is not sealed: %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_MANIFEST_COUNTERS_DO_NOT_MATCH_SEGMENT_CONTENTS_ARG_946818BB =
+ "Logical backup manifest counters do not match segment contents: %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_SEQUENCE_IS_NOT_CONTINUOUS_ACROSS_SEGMENTS_ARG_8547BC6E =
+ "Logical backup sequence is not continuous across segments: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_MANIFEST_IS_INVALID_ARG_CB809CC7 =
+ "Logical backup manifest is invalid: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_CONTAINS_SKIPPED_EVENTS_ARG_E69FD599 =
+ "Logical backup contains skipped events: %s";
+
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_REQUEST_VERSION_ARG_IS_NOT_SUPPORTED_393457D7 =
+ "Logical backup request version %d is not supported";
+ public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_TYPE_ARG_IS_NOT_ALLOWED_7F1CDD38 =
+ "Logical backup request type %d is not allowed";
+ public static final String EXCEPTION_DUPLICATE_LOGICAL_BACKUP_EVENT_GROUP_ID_ARG_15B04C89 =
+ "Duplicate logical backup event group ID: %s";
+ public static final String
+ EXCEPTION_SYMBOLIC_LINKS_ARE_NOT_ALLOWED_IN_LOGICAL_BACKUP_DIRECTORY_PATHS_ARG_7E428569 =
+ "Symbolic links are not allowed in logical backup directory paths: %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_DIRECTORY_IS_UNAVAILABLE_ARG_85F090AD =
+ "Logical backup directory is unavailable: %s";
+
+ private LogicalBackupMessages() {}
+}
diff --git a/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/LogicalBackupMessages.java b/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/LogicalBackupMessages.java
new file mode 100644
index 000000000000..bfc2bed98fed
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/i18n/zh/org/apache/iotdb/commons/i18n/LogicalBackupMessages.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.i18n;
+
+public final class LogicalBackupMessages {
+
+ public static final String EXCEPTION_DIRECTORY_5F8F22B8 = "目录";
+ public static final String EXCEPTION_MANIFEST_7F5CB74A = "清单";
+ public static final String EXCEPTION_FSYNC_POLICY_6D493614 = "fsync 策略";
+ public static final String EXCEPTION_EVENT_GROUP_ID_C6F6268A = "事件组 ID";
+ public static final String EXCEPTION_LOGICAL_BACKUP_DIRECTORY_ALREADY_EXISTS_ARG_1C521D1D =
+ "Pipe 逻辑备份目录已存在:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EVENT_MUST_CONTAIN_AT_LEAST_ONE_REQUEST_0C278BAC =
+ "Pipe 逻辑备份事件必须至少包含一个请求";
+ public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_BODY_MUST_NOT_BE_NULL_EFFD92D9 =
+ "Pipe 逻辑备份请求体不能为空";
+ public static final String EXCEPTION_LOGICAL_BACKUP_RECORD_EXCEEDS_MAX_RECORD_BYTES_B9A9C996 =
+ "Pipe 逻辑备份记录超过 max-record-bytes";
+ public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_MANIFEST_FORMAT_ARG_5005F99E =
+ "不支持的 Pipe 逻辑备份清单格式:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_MANIFEST_DOES_NOT_MATCH_ARG_EXPECTED_ARG_FOUND_ARG_D7BC8AD1 =
+ "Pipe 逻辑备份清单的 %s 不匹配:预期 %s,实际 %s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_DIRECTORY_IS_LOCKED_ARG_A4366800 =
+ "Pipe 逻辑备份目录已被锁定:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_WRITER_IS_CLOSED_EE463BBB =
+ "Pipe 逻辑备份写入器已关闭";
+ public static final String EXCEPTION_INCOMPLETE_LOGICAL_BACKUP_SEGMENT_HEADER_ARG_1FE8371A =
+ "Pipe 逻辑备份 segment 文件头不完整:%s";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_FOOTER_SIZE_ARG_4711E278 =
+ "Pipe 逻辑备份 segment 文件尾大小无效:%s";
+ public static final String
+ EXCEPTION_INVALID_LOGICAL_BACKUP_RECORD_MAGIC_AT_OFFSET_ARG_IN_ARG_C132B9D5 =
+ "偏移 %d 处的 Pipe 逻辑备份记录魔数无效,文件:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_RECORD_HEADER_CRC_MISMATCH_AT_OFFSET_ARG_IN_ARG_7076DD79 =
+ "偏移 %d 处的 Pipe 逻辑备份记录头 CRC 不匹配,文件:%s";
+ public static final String
+ EXCEPTION_INVALID_LOGICAL_BACKUP_RECORD_LENGTH_AT_OFFSET_ARG_IN_ARG_A7F4048E =
+ "偏移 %d 处的 Pipe 逻辑备份记录长度无效,文件:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_RECORD_PAYLOAD_CRC_MISMATCH_AT_OFFSET_ARG_IN_ARG_F9436552 =
+ "偏移 %d 处的 Pipe 逻辑备份记录载荷 CRC 不匹配,文件:%s";
+ public static final String EXCEPTION_UNKNOWN_LOGICAL_BACKUP_RECORD_TYPE_ARG_IN_ARG_22BB599D =
+ "未知的 Pipe 逻辑备份记录类型 %d,文件:%s";
+ public static final String EXCEPTION_NESTED_LOGICAL_BACKUP_EVENT_GROUPS_IN_ARG_896AC47A =
+ "Pipe 逻辑备份事件组发生嵌套,文件:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_COMMIT_WITHOUT_BEGIN_IN_ARG_B9031541 =
+ "Pipe 逻辑备份事件组缺少开始记录,文件:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_OPERATION_INDEX_MISMATCH_AT_OFFSET_ARG_IN_ARG_30C3005F =
+ "偏移 %d 处的 Pipe 逻辑备份操作序号不匹配,文件:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEQUENCE_MISMATCH_AT_OFFSET_ARG_IN_ARG_CCEC352B =
+ "偏移 %d 处的 Pipe 逻辑备份 sequence 不连续,文件:%s";
+ public static final String EXCEPTION_INCOMPLETE_LOGICAL_BACKUP_RECORD_TAIL_ARG_F722DEE7 =
+ "Pipe 逻辑备份记录尾部不完整:%s";
+ public static final String
+ EXCEPTION_SEALED_LOGICAL_BACKUP_SEGMENT_CONTAINS_AN_OPEN_EVENT_GROUP_ARG_54C274D8 =
+ "已封存的 Pipe 逻辑备份 segment 包含未提交事件组:%s";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_MAGIC_ARG_8340BA73 =
+ "Pipe 逻辑备份 segment 魔数无效:%s";
+ public static final String
+ EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_SEGMENT_MAJOR_VERSION_ARG_ARG_CE07F1CB =
+ "不支持的 Pipe 逻辑备份 segment 主版本 %d:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_HEADER_CRC_MISMATCH_ARG_07466D55 =
+ "Pipe 逻辑备份 segment 文件头 CRC 不匹配:%s";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_FOOTER_MAGIC_ARG_D973E2B4 =
+ "Pipe 逻辑备份 segment 文件尾魔数无效:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_ID_MISMATCH_ARG_D832F14D =
+ "Pipe 逻辑备份 segment 文件尾 ID 不匹配:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_DIGEST_MISMATCH_ARG_85346116 =
+ "Pipe 逻辑备份 segment 摘要不匹配:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_CRC_MISMATCH_ARG_25FB7D91 =
+ "Pipe 逻辑备份 segment 文件尾 CRC 不匹配:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_METADATA_MISMATCH_ARG_77D1FADB =
+ "Pipe 逻辑备份 segment 文件尾元数据不匹配:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EVENT_ID_ARG_WAS_ALREADY_WRITTEN_WITH_A_DIFFERENT_DIGEST_6A297330 =
+ "Pipe 逻辑备份事件 ID %s 已使用不同摘要写入";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_WRITER_CONFIGURATION_707BCC99 =
+ "Pipe 逻辑备份写入器配置无效:segment-size、max-record、fsync-batch 和 fsync-period"
+ + " 必须为正数";
+ public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_OUTSIDE_EVENT_GROUP_8351818F =
+ "偏移 %d 处的 Pipe 逻辑备份请求不在事件组中,文件:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_CONTROL_RECORD_INSIDE_EVENT_GROUP_C45D158B =
+ "偏移 %d 处的 Pipe 逻辑备份控制记录位于事件组内,文件:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_ID_MISMATCH_ARG_9FE7E88A =
+ "Pipe 逻辑备份 segment ID 不匹配:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEQUENCE_GAP_IN_ARG_D8698149 =
+ "Pipe 逻辑备份 sequence 在 %s 中不连续";
+ public static final String EXCEPTION_LOGICAL_BACKUP_HAS_NO_SEGMENTS_B48D5F15 =
+ "Pipe 逻辑备份不包含 segment";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_PATH_ARG_6485A845 =
+ "Pipe 逻辑备份 segment 路径无效:%s";
+ public static final String EXCEPTION_NO_LOGICAL_BACKUP_MANIFEST_FOUND_UNDER_ARG_4ACEA70E =
+ "在 %s 下未找到 Pipe 逻辑备份 manifest";
+ public static final String EXCEPTION_DUPLICATE_LOGICAL_BACKUP_STREAM_ID_ARG_1BF0F6EE =
+ "Pipe 逻辑备份 stream ID 重复:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_MANIFEST_HAS_NO_SEGMENTS_ARG_AFB5C138 =
+ "Pipe 逻辑备份 manifest 不包含 segment:%s";
+ public static final String EXCEPTION_UNLISTED_LOGICAL_BACKUP_SEGMENT_ARG_FF548C0B =
+ "未在 manifest 中列出 Pipe 逻辑备份 segment:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_METADATA_MISMATCH_ARG_376FD0B3 =
+ "Pipe 逻辑备份 segment 元数据不匹配:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_SEGMENT_IS_NOT_SEALED_ARG_937793FF =
+ "Pipe 逻辑备份 segment 尚未封存:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_MANIFEST_COUNTERS_DO_NOT_MATCH_SEGMENT_CONTENTS_ARG_946818BB =
+ "Pipe 逻辑备份 manifest 计数与 segment 内容不匹配:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_SEQUENCE_IS_NOT_CONTINUOUS_ACROSS_SEGMENTS_ARG_8547BC6E =
+ "Pipe 逻辑备份 sequence 在 segment 之间不连续:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_MANIFEST_IS_INVALID_ARG_CB809CC7 =
+ "Pipe 逻辑备份 manifest 无效:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_CONTAINS_SKIPPED_EVENTS_ARG_E69FD599 =
+ "Pipe 逻辑备份包含已跳过的事件:%s";
+
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_REQUEST_VERSION_ARG_IS_NOT_SUPPORTED_393457D7 =
+ "不支持逻辑备份请求版本 %d";
+ public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_TYPE_ARG_IS_NOT_ALLOWED_7F1CDD38 =
+ "不允许逻辑备份请求类型 %d";
+ public static final String EXCEPTION_DUPLICATE_LOGICAL_BACKUP_EVENT_GROUP_ID_ARG_15B04C89 =
+ "逻辑备份事件组 ID 重复:%s";
+ public static final String
+ EXCEPTION_SYMBOLIC_LINKS_ARE_NOT_ALLOWED_IN_LOGICAL_BACKUP_DIRECTORY_PATHS_ARG_7E428569 =
+ "逻辑备份目录路径中不允许使用符号链接:%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_DIRECTORY_IS_UNAVAILABLE_ARG_85F090AD =
+ "逻辑备份目录不可用:%s";
+
+ private LogicalBackupMessages() {}
+}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
index 76b45d1e7989..2a10db843618 100644
--- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
@@ -38,6 +38,7 @@
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSslSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSyncSink;
+import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.logicalbackup.LogicalBackupSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.opcda.OpcDaSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.opcua.OpcUaSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.websocket.WebSocketSink;
@@ -95,6 +96,7 @@ public enum BuiltinPipePlugin {
OPC_UA_CONNECTOR("opc-ua-connector", OpcUaSink.class),
OPC_DA_CONNECTOR("opc-da-connector", OpcDaSink.class),
WRITE_BACK_CONNECTOR("write-back-connector", WriteBackSink.class),
+ LOGICAL_BACKUP_CONNECTOR("logical-backup-connector", LogicalBackupSink.class),
DO_NOTHING_SINK("do-nothing-sink", DoNothingSink.class),
IOTDB_THRIFT_SINK("iotdb-thrift-sink", IoTDBThriftSink.class),
@@ -107,6 +109,7 @@ public enum BuiltinPipePlugin {
OPC_UA_SINK("opc-ua-sink", OpcUaSink.class),
OPC_DA_SINK("opc-da-sink", OpcDaSink.class),
WRITE_BACK_SINK("write-back-sink", WriteBackSink.class),
+ LOGICAL_BACKUP_SINK("logical-backup-sink", LogicalBackupSink.class),
SUBSCRIPTION_SINK("subscription-sink", DoNothingSink.class),
IOT_CONSENSUS_V2_ASYNC_SINK("iot-consensus-v2-async-sink", IoTConsensusV2AsyncSink.class),
// Legacy alias for stale PipeMeta written before the PipeConsensus -> IoTConsensusV2 rename.
@@ -178,6 +181,7 @@ public String getClassName() {
OPC_UA_CONNECTOR.getPipePluginName().toUpperCase(),
OPC_DA_CONNECTOR.getPipePluginName().toUpperCase(),
WRITE_BACK_CONNECTOR.getPipePluginName().toUpperCase(),
+ LOGICAL_BACKUP_CONNECTOR.getPipePluginName().toUpperCase(),
IOT_CONSENSUS_V2_ASYNC_CONNECTOR.getPipePluginName().toUpperCase(),
PIPE_CONSENSUS_ASYNC_CONNECTOR.getPipePluginName().toUpperCase(),
// Sinks
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/sink/logicalbackup/LogicalBackupSink.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/sink/logicalbackup/LogicalBackupSink.java
new file mode 100644
index 000000000000..d5fb9b22021e
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/sink/logicalbackup/LogicalBackupSink.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.logicalbackup;
+
+import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.PlaceholderSink;
+
+public class LogicalBackupSink extends PlaceholderSink {}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/config/constant/PipeSinkConstant.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/config/constant/PipeSinkConstant.java
index f2d9ea283c82..6d5dac18c3d9 100644
--- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/config/constant/PipeSinkConstant.java
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/config/constant/PipeSinkConstant.java
@@ -80,6 +80,55 @@ public class PipeSinkConstant {
public static final String SINK_REALTIME_FIRST_KEY = "sink.realtime-first";
public static final boolean CONNECTOR_REALTIME_FIRST_DEFAULT_VALUE = true;
+ public static final String CONNECTOR_LOGICAL_BACKUP_DIR_KEY = "connector.dir";
+ public static final String SINK_LOGICAL_BACKUP_DIR_KEY = "sink.dir";
+ public static final String CONNECTOR_LOGICAL_BACKUP_ID_KEY = "connector.backup-id";
+ public static final String SINK_LOGICAL_BACKUP_ID_KEY = "sink.backup-id";
+ public static final String CONNECTOR_LOGICAL_BACKUP_RESUME_KEY = "connector.resume";
+ public static final String SINK_LOGICAL_BACKUP_RESUME_KEY = "sink.resume";
+ public static final String LOGICAL_BACKUP_RESUME_FAIL_IF_EXISTS = "fail-if-exists";
+ public static final String LOGICAL_BACKUP_RESUME_APPEND = "append";
+ public static final String LOGICAL_BACKUP_RESUME_NEW = "new";
+ public static final String LOGICAL_BACKUP_RESUME_DEFAULT_VALUE =
+ LOGICAL_BACKUP_RESUME_FAIL_IF_EXISTS;
+ public static final String CONNECTOR_LOGICAL_BACKUP_SEGMENT_SIZE_BYTES_KEY =
+ "connector.segment-size-bytes";
+ public static final String SINK_LOGICAL_BACKUP_SEGMENT_SIZE_BYTES_KEY = "sink.segment-size-bytes";
+ public static final long LOGICAL_BACKUP_SEGMENT_SIZE_BYTES_DEFAULT_VALUE = 256L * MB;
+ public static final String CONNECTOR_LOGICAL_BACKUP_MAX_RECORD_BYTES_KEY =
+ "connector.max-record-bytes";
+ public static final String SINK_LOGICAL_BACKUP_MAX_RECORD_BYTES_KEY = "sink.max-record-bytes";
+ public static final int LOGICAL_BACKUP_MAX_RECORD_BYTES_DEFAULT_VALUE = 64 * 1024 * 1024;
+ public static final String CONNECTOR_LOGICAL_BACKUP_FSYNC_POLICY_KEY = "connector.fsync-policy";
+ public static final String SINK_LOGICAL_BACKUP_FSYNC_POLICY_KEY = "sink.fsync-policy";
+ public static final String LOGICAL_BACKUP_FSYNC_ALWAYS = "always";
+ public static final String LOGICAL_BACKUP_FSYNC_BATCH = "batch";
+ public static final String LOGICAL_BACKUP_FSYNC_PERIODIC = "periodic";
+ public static final String LOGICAL_BACKUP_FSYNC_NONE = "none";
+ public static final String LOGICAL_BACKUP_FSYNC_POLICY_DEFAULT_VALUE =
+ LOGICAL_BACKUP_FSYNC_ALWAYS;
+ public static final String CONNECTOR_LOGICAL_BACKUP_FSYNC_BATCH_OPERATIONS_KEY =
+ "connector.fsync-batch-operations";
+ public static final String SINK_LOGICAL_BACKUP_FSYNC_BATCH_OPERATIONS_KEY =
+ "sink.fsync-batch-operations";
+ public static final int LOGICAL_BACKUP_FSYNC_BATCH_OPERATIONS_DEFAULT_VALUE = 1000;
+ public static final String CONNECTOR_LOGICAL_BACKUP_FSYNC_PERIOD_MS_KEY =
+ "connector.fsync-period-ms";
+ public static final String SINK_LOGICAL_BACKUP_FSYNC_PERIOD_MS_KEY = "sink.fsync-period-ms";
+ public static final long LOGICAL_BACKUP_FSYNC_PERIOD_MS_DEFAULT_VALUE = 1000;
+ public static final String CONNECTOR_LOGICAL_BACKUP_UNSUPPORTED_EVENT_KEY =
+ "connector.on-unsupported-event";
+ public static final String SINK_LOGICAL_BACKUP_UNSUPPORTED_EVENT_KEY =
+ "sink.on-unsupported-event";
+ public static final String LOGICAL_BACKUP_UNSUPPORTED_EVENT_FAIL = "fail";
+ public static final String LOGICAL_BACKUP_UNSUPPORTED_EVENT_SKIP = "skip";
+ public static final String LOGICAL_BACKUP_UNSUPPORTED_EVENT_DEFAULT_VALUE =
+ LOGICAL_BACKUP_UNSUPPORTED_EVENT_FAIL;
+ public static final String CONNECTOR_LOGICAL_BACKUP_INCLUDE_HEARTBEAT_KEY =
+ "connector.include-heartbeat";
+ public static final String SINK_LOGICAL_BACKUP_INCLUDE_HEARTBEAT_KEY = "sink.include-heartbeat";
+ public static final boolean LOGICAL_BACKUP_INCLUDE_HEARTBEAT_DEFAULT_VALUE = false;
+
public static final String CONNECTOR_SERIALIZE_BY_REGION_KEY = "connector.serialize-by-region";
public static final String SINK_SERIALIZE_BY_REGION_KEY = "sink.serialize-by-region";
public static final boolean CONNECTOR_SERIALIZE_BY_REGION_DEFAULT_VALUE = false;
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReader.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReader.java
new file mode 100644
index 000000000000..6ac9ca23f67b
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReader.java
@@ -0,0 +1,481 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import org.apache.iotdb.commons.i18n.LogicalBackupMessages;
+import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.IoTDBSinkRequestVersion;
+import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeRequestType;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class LogicalBackupArchiveReader {
+
+ private static final Gson GSON = new Gson();
+ private static final Set REPLAYABLE_REQUEST_TYPES =
+ Set.of(
+ PipeRequestType.TRANSFER_TABLET_INSERT_NODE_V2.getType(),
+ PipeRequestType.TRANSFER_TABLET_RAW_V2.getType(),
+ PipeRequestType.TRANSFER_PLAN_NODE.getType(),
+ PipeRequestType.TRANSFER_SCHEMA_SNAPSHOT_PIECE.getType(),
+ PipeRequestType.TRANSFER_SCHEMA_SNAPSHOT_SEAL.getType());
+
+ public List read(final Path source, final boolean allowIncomplete)
+ throws IOException {
+ final Path normalizedSource = source.toAbsolutePath().normalize();
+ final List manifests = discoverManifests(normalizedSource);
+ if (manifests.isEmpty()) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_NO_LOGICAL_BACKUP_MANIFEST_FOUND_UNDER_ARG_4ACEA70E,
+ source));
+ }
+ final List streams = new ArrayList<>();
+ final Set streamIds = new HashSet<>();
+ LogicalBackupManifest archiveIdentity = null;
+ for (final Path manifest : manifests) {
+ final BackupStream stream = readStream(manifest, allowIncomplete);
+ if (archiveIdentity == null) {
+ archiveIdentity = stream.getManifest();
+ } else {
+ validateArchiveIdentity(archiveIdentity, stream.getManifest());
+ }
+ if (!streamIds.add(stream.getManifest().streamId)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_DUPLICATE_LOGICAL_BACKUP_STREAM_ID_ARG_1BF0F6EE,
+ stream.getManifest().streamId));
+ }
+ streams.add(stream);
+ }
+ streams.sort(
+ Comparator.comparingInt(
+ (BackupStream stream) ->
+ "schema".equalsIgnoreCase(stream.getManifest().streamType) ? 0 : 1)
+ .thenComparing((BackupStream stream) -> stream.getManifest().streamId));
+ return Collections.unmodifiableList(streams);
+ }
+
+ private static void validateArchiveIdentity(
+ final LogicalBackupManifest expected, final LogicalBackupManifest actual) throws IOException {
+ validateArchiveIdentityField("backupId", expected.backupId, actual.backupId);
+ validateArchiveIdentityField("pipeName", expected.pipeName, actual.pipeName);
+ validateArchiveIdentityField(
+ "pipeCreationTime", expected.pipeCreationTime, actual.pipeCreationTime);
+ validateArchiveIdentityField(
+ "sourceClusterId", expected.sourceClusterId, actual.sourceClusterId);
+ validateArchiveIdentityField("sourceVersion", expected.sourceVersion, actual.sourceVersion);
+ validateArchiveIdentityField(
+ "timestampPrecision", expected.timestampPrecision, actual.timestampPrecision);
+ }
+
+ private static void validateArchiveIdentityField(
+ final String field, final Object expected, final Object actual) throws IOException {
+ if (!java.util.Objects.equals(expected, actual)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_MANIFEST_DOES_NOT_MATCH_ARG_EXPECTED_ARG_FOUND_ARG_D7BC8AD1,
+ field,
+ expected,
+ actual));
+ }
+ }
+
+ private BackupStream readStream(final Path manifestPath, final boolean allowIncomplete)
+ throws IOException {
+ final LogicalBackupManifest manifest;
+ try {
+ manifest =
+ GSON.fromJson(
+ Files.readString(manifestPath, StandardCharsets.UTF_8), LogicalBackupManifest.class);
+ } catch (final JsonParseException | NullPointerException e) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_MANIFEST_IS_INVALID_ARG_CB809CC7,
+ manifestPath),
+ e);
+ }
+ validateManifest(manifest, manifestPath);
+ if (!allowIncomplete && manifest.skippedEventCount > 0) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_CONTAINS_SKIPPED_EVENTS_ARG_E69FD599,
+ manifestPath));
+ }
+
+ final Path streamDirectory = manifestPath.getParent().toAbsolutePath().normalize();
+ final LogicalBackupSegmentReader segmentReader =
+ new LogicalBackupSegmentReader(manifest.maxRecordBytes);
+ final List records = new ArrayList<>();
+ final Map operationCounts = new LinkedHashMap<>();
+ final Set listedSegments = new HashSet<>();
+ long expectedSequence = -1;
+ long expectedSegmentId = 0;
+
+ for (int index = 0; index < manifest.segments.size(); index++) {
+ final LogicalBackupManifest.Segment segment = manifest.segments.get(index);
+ if (segment == null) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_PATH_ARG_6485A845,
+ "null"));
+ }
+ final Path segmentPath = resolveSegmentPath(streamDirectory, segment.file);
+ if (segmentPath == null
+ || Files.isSymbolicLink(segmentPath)
+ || !Files.isRegularFile(segmentPath, LinkOption.NOFOLLOW_LINKS)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_PATH_ARG_6485A845,
+ segment.file));
+ }
+ listedSegments.add(segmentPath);
+ final boolean lastSegment = index == manifest.segments.size() - 1;
+ final LogicalBackupSegmentReader.ScanResult scan =
+ segmentReader.scan(segmentPath, allowIncomplete && lastSegment);
+ if (scan.getSegmentId() != segment.segmentId || scan.getSegmentId() != expectedSegmentId++) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_SEGMENT_ID_MISMATCH_ARG_9FE7E88A,
+ segmentPath));
+ }
+ if (!scan.isSealed() && (!allowIncomplete || !lastSegment)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_SEGMENT_IS_NOT_SEALED_ARG_937793FF,
+ segmentPath));
+ }
+ if (scan.isSealed()) {
+ validateSegmentManifest(segment, scan, segmentPath);
+ }
+ for (final LogicalBackupRecord record : scan.getRecords()) {
+ validateReplayableRequest(record);
+ }
+
+ final List committedRecords =
+ scan.hasOpenEventGroup() ? removeOpenEventGroup(scan.getRecords()) : scan.getRecords();
+ for (final LogicalBackupRecord record : committedRecords) {
+ if (expectedSequence >= 0 && record.getSequence() != expectedSequence) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_SEQUENCE_IS_NOT_CONTINUOUS_ACROSS_SEGMENTS_ARG_8547BC6E,
+ manifestPath));
+ }
+ expectedSequence = record.getSequence() + 1;
+ records.add(record);
+ operationCounts.merge(record.getRecordType().name(), 1L, Long::sum);
+ }
+ }
+
+ if (!allowIncomplete) {
+ validateUnlistedSegments(streamDirectory, listedSegments);
+ final long skippedEventCount =
+ operationCounts.getOrDefault(LogicalBackupRecordType.SKIPPED_EVENT.name(), 0L);
+ if (skippedEventCount > 0) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_CONTAINS_SKIPPED_EVENTS_ARG_E69FD599,
+ manifestPath));
+ }
+ if (skippedEventCount != manifest.skippedEventCount
+ || !operationCounts.equals(manifest.operationCounts)
+ || (records.isEmpty()
+ ? manifest.firstSequence != -1 || manifest.lastSequence != -1
+ : records.get(0).getSequence() != manifest.firstSequence
+ || records.get(records.size() - 1).getSequence() != manifest.lastSequence)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_MANIFEST_COUNTERS_DO_NOT_MATCH_SEGMENT_CONTENTS_ARG_946818BB,
+ manifestPath));
+ }
+ }
+ return new BackupStream(manifestPath, manifest, records, toEventGroups(records));
+ }
+
+ private static void validateReplayableRequest(final LogicalBackupRecord record)
+ throws IOException {
+ if (record.getRecordType() != LogicalBackupRecordType.PIPE_REQUEST) {
+ return;
+ }
+ if (record.getRequestVersion() != IoTDBSinkRequestVersion.VERSION_1.getVersion()) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_REQUEST_VERSION_ARG_IS_NOT_SUPPORTED_393457D7,
+ record.getRequestVersion()));
+ }
+ if (!REPLAYABLE_REQUEST_TYPES.contains(record.getRequestType())) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_REQUEST_TYPE_ARG_IS_NOT_ALLOWED_7F1CDD38,
+ record.getRequestType()));
+ }
+ }
+
+ private static List discoverManifests(final Path source) throws IOException {
+ if (!Files.isSymbolicLink(source)
+ && Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)
+ && LogicalBackupFormat.MANIFEST_FILE_NAME.equals(source.getFileName().toString())) {
+ return Collections.singletonList(source);
+ }
+ if (!Files.isDirectory(source)) {
+ return Collections.emptyList();
+ }
+ try (final Stream paths = Files.walk(source)) {
+ return paths
+ .filter(path -> !Files.isSymbolicLink(path))
+ .filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
+ .filter(
+ path -> LogicalBackupFormat.MANIFEST_FILE_NAME.equals(path.getFileName().toString()))
+ .sorted()
+ .collect(Collectors.toList());
+ }
+ }
+
+ private static Path resolveSegmentPath(final Path streamDirectory, final String segmentFile) {
+ if (segmentFile == null) {
+ return null;
+ }
+ final Path relativePath;
+ try {
+ relativePath = Path.of(segmentFile);
+ } catch (final RuntimeException e) {
+ return null;
+ }
+ if (relativePath.isAbsolute() || relativePath.getNameCount() != 1) {
+ return null;
+ }
+ final Path segmentPath = streamDirectory.resolve(relativePath).normalize();
+ return segmentPath.startsWith(streamDirectory)
+ && segmentPath.getParent().equals(streamDirectory)
+ ? segmentPath
+ : null;
+ }
+
+ private static void validateManifest(
+ final LogicalBackupManifest manifest, final Path manifestPath) throws IOException {
+ if (manifest == null
+ || !LogicalBackupFormat.FORMAT_NAME.equals(manifest.formatName)
+ || !LogicalBackupFormat.FORMAT_VERSION.equals(manifest.formatVersion)
+ || manifest.streamId == null
+ || manifest.backupId == null
+ || manifest.timestampPrecision == null
+ || manifest.maxRecordBytes <= 0
+ || manifest.maxRecordBytes > LogicalBackupFormat.MAX_RECORD_BYTES
+ || manifest.skippedEventCount < 0) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_MANIFEST_IS_INVALID_ARG_CB809CC7,
+ manifestPath));
+ }
+ if (manifest.segments == null || manifest.segments.isEmpty()) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_MANIFEST_HAS_NO_SEGMENTS_ARG_AFB5C138,
+ manifestPath));
+ }
+ if (manifest.operationCounts == null) {
+ manifest.operationCounts = new LinkedHashMap<>();
+ }
+ }
+
+ private static void validateSegmentManifest(
+ final LogicalBackupManifest.Segment segment,
+ final LogicalBackupSegmentReader.ScanResult scan,
+ final Path segmentPath)
+ throws IOException {
+ final LogicalBackupSegmentReader.Footer footer = scan.getFooter();
+ if (!"SEALED".equals(segment.status)
+ || segment.firstSequence != footer.getFirstSequence()
+ || segment.lastSequence != footer.getLastSequence()
+ || segment.recordCount != footer.getRecordCount()
+ || segment.sizeBytes != scan.getValidLength()
+ || !LogicalBackupFormat.toHex(footer.getDigest()).equals(segment.sha256)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_SEGMENT_METADATA_MISMATCH_ARG_376FD0B3,
+ segmentPath));
+ }
+ }
+
+ private static void validateUnlistedSegments(
+ final Path streamDirectory, final Set listedSegments) throws IOException {
+ try (final Stream paths = Files.list(streamDirectory)) {
+ final Path unlisted =
+ paths
+ .filter(Files::isRegularFile)
+ .filter(path -> path.getFileName().toString().endsWith(".pwal"))
+ .map(path -> path.toAbsolutePath().normalize())
+ .filter(path -> !listedSegments.contains(path))
+ .findFirst()
+ .orElse(null);
+ if (unlisted != null) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_UNLISTED_LOGICAL_BACKUP_SEGMENT_ARG_FF548C0B,
+ unlisted));
+ }
+ }
+ }
+
+ private static List removeOpenEventGroup(
+ final List records) {
+ for (int i = records.size() - 1; i >= 0; i--) {
+ if (records.get(i).getRecordType() == LogicalBackupRecordType.EVENT_BEGIN) {
+ return records.subList(0, i);
+ }
+ }
+ return records;
+ }
+
+ private static List toEventGroups(final List records)
+ throws IOException {
+ final List groups = new ArrayList<>();
+ final Set eventGroupIds = new HashSet<>();
+ UUID eventGroupId = null;
+ long firstSequence = -1;
+ String metadata = "";
+ final List requests = new ArrayList<>();
+ for (final LogicalBackupRecord record : records) {
+ if (record.getRecordType() == LogicalBackupRecordType.EVENT_BEGIN) {
+ eventGroupId = record.getEventGroupId();
+ if (!eventGroupIds.add(eventGroupId)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_DUPLICATE_LOGICAL_BACKUP_EVENT_GROUP_ID_ARG_15B04C89,
+ eventGroupId));
+ }
+ firstSequence = record.getSequence();
+ metadata = record.getMetadata();
+ requests.clear();
+ } else if (record.getRecordType() == LogicalBackupRecordType.PIPE_REQUEST) {
+ requests.add(record);
+ } else if (record.getRecordType() == LogicalBackupRecordType.EVENT_COMMIT) {
+ groups.add(
+ new EventGroup(
+ eventGroupId,
+ firstSequence,
+ record.getSequence(),
+ metadata,
+ new ArrayList<>(requests)));
+ eventGroupId = null;
+ requests.clear();
+ }
+ }
+ return Collections.unmodifiableList(groups);
+ }
+
+ public static class BackupStream {
+ private final Path manifestPath;
+ private final LogicalBackupManifest manifest;
+ private final List records;
+ private final List eventGroups;
+
+ private BackupStream(
+ final Path manifestPath,
+ final LogicalBackupManifest manifest,
+ final List records,
+ final List eventGroups) {
+ this.manifestPath = manifestPath;
+ this.manifest = manifest;
+ this.records = Collections.unmodifiableList(records);
+ this.eventGroups = eventGroups;
+ }
+
+ public Path getManifestPath() {
+ return manifestPath;
+ }
+
+ public LogicalBackupManifest getManifest() {
+ return manifest;
+ }
+
+ public List getRecords() {
+ return records;
+ }
+
+ public List getEventGroups() {
+ return eventGroups;
+ }
+ }
+
+ public static class EventGroup {
+ private final UUID eventGroupId;
+ private final long firstSequence;
+ private final long lastSequence;
+ private final String metadata;
+ private final List requests;
+
+ private EventGroup(
+ final UUID eventGroupId,
+ final long firstSequence,
+ final long lastSequence,
+ final String metadata,
+ final List requests) {
+ this.eventGroupId = eventGroupId;
+ this.firstSequence = firstSequence;
+ this.lastSequence = lastSequence;
+ this.metadata = metadata;
+ this.requests = Collections.unmodifiableList(requests);
+ }
+
+ public UUID getEventGroupId() {
+ return eventGroupId;
+ }
+
+ public long getFirstSequence() {
+ return firstSequence;
+ }
+
+ public long getLastSequence() {
+ return lastSequence;
+ }
+
+ public String getMetadata() {
+ return metadata;
+ }
+
+ public List getRequests() {
+ return requests;
+ }
+ }
+}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupFormat.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupFormat.java
new file mode 100644
index 000000000000..6309595202b5
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupFormat.java
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.zip.CRC32C;
+
+public final class LogicalBackupFormat {
+
+ public static final String FORMAT_NAME = "iotdb-pipe-logical-backup";
+ public static final String FORMAT_VERSION = "1.0";
+ public static final short MAJOR_VERSION = 1;
+ public static final short MINOR_VERSION = 0;
+ public static final long SEGMENT_MAGIC = 0x494F545057414C31L;
+ public static final int RECORD_MAGIC = 0x50575231;
+ public static final int FOOTER_MAGIC = 0x50575346;
+ public static final int SEGMENT_HEADER_SIZE = 32;
+ public static final int RECORD_HEADER_SIZE = 56;
+ public static final int SEGMENT_FOOTER_SIZE = 96;
+ public static final int SHA256_SIZE = 32;
+ public static final int MAX_RECORD_BYTES = 256 * 1024 * 1024;
+ public static final String MANIFEST_FILE_NAME = "manifest.json";
+
+ private LogicalBackupFormat() {}
+
+ public static int crc32c(final byte[] bytes, final int offset, final int length) {
+ final CRC32C crc32c = new CRC32C();
+ crc32c.update(bytes, offset, length);
+ return (int) crc32c.getValue();
+ }
+
+ public static MessageDigest newSha256() {
+ try {
+ return MessageDigest.getInstance("SHA-256");
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ public static String toHex(final byte[] bytes) {
+ return HexFormat.of().formatHex(bytes);
+ }
+}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupManifest.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupManifest.java
new file mode 100644
index 000000000000..3742ca88d801
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupManifest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public class LogicalBackupManifest {
+
+ public String formatName = LogicalBackupFormat.FORMAT_NAME;
+ public String formatVersion = LogicalBackupFormat.FORMAT_VERSION;
+ public String backupId;
+ public String status;
+ public String pipeName;
+ public long pipeCreationTime;
+ public String sourceClusterId;
+ public String sourceVersion;
+ public String timestampPrecision;
+ public String createdAt;
+ public String closedAt;
+ public String streamId;
+ public int regionId;
+ public String sinkTaskId;
+ public String streamType;
+ public String fsyncPolicy;
+ public long segmentSizeBytes;
+ public int maxRecordBytes;
+ public long firstSequence = -1;
+ public long lastSequence = -1;
+ public long lastDurableSequence = -1;
+ public String lastEventGroupId;
+ public String lastEventDigest;
+ public long lastEventFirstSequence = -1;
+ public long skippedEventCount;
+ public boolean recovered;
+ public Map operationCounts = new LinkedHashMap<>();
+ public List segments = new ArrayList<>();
+
+ public static class Segment {
+ public String file;
+ public long segmentId;
+ public long firstSequence = -1;
+ public long lastSequence = -1;
+ public long recordCount;
+ public long sizeBytes;
+ public String sha256;
+ public String status;
+ }
+}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupRecord.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupRecord.java
new file mode 100644
index 000000000000..b779c4fc4aa3
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupRecord.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import org.apache.iotdb.service.rpc.thrift.TPipeTransferReq;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.UUID;
+
+public class LogicalBackupRecord {
+
+ private final LogicalBackupRecordType recordType;
+ private final long sequence;
+ private final UUID eventGroupId;
+ private final int operationIndex;
+ private final long eventTime;
+ private final byte requestVersion;
+ private final short requestType;
+ private final String metadata;
+ private final byte[] payload;
+
+ public LogicalBackupRecord(
+ final LogicalBackupRecordType recordType,
+ final long sequence,
+ final UUID eventGroupId,
+ final int operationIndex,
+ final long eventTime,
+ final byte requestVersion,
+ final short requestType,
+ final String metadata,
+ final byte[] payload) {
+ this.recordType = recordType;
+ this.sequence = sequence;
+ this.eventGroupId = eventGroupId;
+ this.operationIndex = operationIndex;
+ this.eventTime = eventTime;
+ this.requestVersion = requestVersion;
+ this.requestType = requestType;
+ this.metadata = metadata;
+ this.payload = payload;
+ }
+
+ public LogicalBackupRecordType getRecordType() {
+ return recordType;
+ }
+
+ public long getSequence() {
+ return sequence;
+ }
+
+ public UUID getEventGroupId() {
+ return eventGroupId;
+ }
+
+ public int getOperationIndex() {
+ return operationIndex;
+ }
+
+ public long getEventTime() {
+ return eventTime;
+ }
+
+ public byte getRequestVersion() {
+ return requestVersion;
+ }
+
+ public short getRequestType() {
+ return requestType;
+ }
+
+ public String getMetadata() {
+ return metadata;
+ }
+
+ public byte[] getPayload() {
+ return payload;
+ }
+
+ public TPipeTransferReq toTPipeTransferReq() {
+ return new TPipeTransferReq()
+ .setVersion(requestVersion)
+ .setType(requestType)
+ .setBody(ByteBuffer.wrap(payload));
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof LogicalBackupRecord)) {
+ return false;
+ }
+ final LogicalBackupRecord that = (LogicalBackupRecord) obj;
+ return sequence == that.sequence
+ && operationIndex == that.operationIndex
+ && eventTime == that.eventTime
+ && requestVersion == that.requestVersion
+ && requestType == that.requestType
+ && recordType == that.recordType
+ && eventGroupId.equals(that.eventGroupId)
+ && metadata.equals(that.metadata)
+ && Arrays.equals(payload, that.payload);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = eventGroupId.hashCode();
+ result = 31 * result + recordType.hashCode();
+ result = 31 * result + Long.hashCode(sequence);
+ result = 31 * result + operationIndex;
+ result = 31 * result + Long.hashCode(eventTime);
+ result = 31 * result + requestVersion;
+ result = 31 * result + requestType;
+ result = 31 * result + metadata.hashCode();
+ return 31 * result + Arrays.hashCode(payload);
+ }
+}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupRecordType.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupRecordType.java
new file mode 100644
index 000000000000..e188669548ca
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupRecordType.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public enum LogicalBackupRecordType {
+ EVENT_BEGIN((byte) 1),
+ PIPE_REQUEST((byte) 2),
+ EVENT_COMMIT((byte) 3),
+ HEARTBEAT((byte) 4),
+ SKIPPED_EVENT((byte) 5),
+ STREAM_END((byte) 6);
+
+ private static final Map TYPE_MAP = new HashMap<>();
+
+ static {
+ for (final LogicalBackupRecordType type : values()) {
+ TYPE_MAP.put(type.code, type);
+ }
+ }
+
+ private final byte code;
+
+ LogicalBackupRecordType(final byte code) {
+ this.code = code;
+ }
+
+ public byte getCode() {
+ return code;
+ }
+
+ public static LogicalBackupRecordType valueOf(final byte code) {
+ return TYPE_MAP.get(code);
+ }
+}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupSegmentReader.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupSegmentReader.java
new file mode 100644
index 000000000000..e400d6133c27
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupSegmentReader.java
@@ -0,0 +1,541 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import org.apache.iotdb.commons.i18n.LogicalBackupMessages;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+public class LogicalBackupSegmentReader {
+
+ private final int maxRecordBytes;
+
+ public LogicalBackupSegmentReader(final int maxRecordBytes) {
+ this.maxRecordBytes = maxRecordBytes;
+ }
+
+ public ScanResult scan(final Path segment, final boolean allowIncompleteTail) throws IOException {
+ try (final RandomAccessFile reader = new RandomAccessFile(segment.toFile(), "r")) {
+ final long fileLength = reader.length();
+ if (fileLength < LogicalBackupFormat.SEGMENT_HEADER_SIZE) {
+ throw new EOFException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_INCOMPLETE_LOGICAL_BACKUP_SEGMENT_HEADER_ARG_1FE8371A,
+ segment));
+ }
+
+ final byte[] segmentHeader = readBytes(reader, LogicalBackupFormat.SEGMENT_HEADER_SIZE);
+ validateSegmentHeader(segmentHeader, segment);
+ final ByteBuffer segmentHeaderBuffer = wrap(segmentHeader);
+ segmentHeaderBuffer.position(Long.BYTES + Short.BYTES * 2);
+ final long segmentId = segmentHeaderBuffer.getLong();
+ final long createdAt = segmentHeaderBuffer.getLong();
+
+ final MessageDigest digest = LogicalBackupFormat.newSha256();
+ digest.update(segmentHeader);
+ final List records = new ArrayList<>();
+ long validLength = LogicalBackupFormat.SEGMENT_HEADER_SIZE;
+ long lastCommittedLength = validLength;
+ boolean openEventGroup = false;
+ UUID openEventGroupId = null;
+ int nextOperationIndex = 0;
+ long expectedSequence = -1;
+ boolean incompleteTail = false;
+ Footer footer = null;
+
+ while (reader.getFilePointer() < fileLength) {
+ final long frameOffset = reader.getFilePointer();
+ if (fileLength - frameOffset < Integer.BYTES) {
+ incompleteTail = true;
+ break;
+ }
+
+ final int magic = reader.readInt();
+ reader.seek(frameOffset);
+ if (magic == LogicalBackupFormat.FOOTER_MAGIC) {
+ if (fileLength - frameOffset < LogicalBackupFormat.SEGMENT_FOOTER_SIZE) {
+ incompleteTail = true;
+ break;
+ }
+ if (fileLength - frameOffset > LogicalBackupFormat.SEGMENT_FOOTER_SIZE) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_FOOTER_SIZE_ARG_4711E278,
+ segment));
+ }
+ final byte[] footerBytes = readBytes(reader, LogicalBackupFormat.SEGMENT_FOOTER_SIZE);
+ footer =
+ parseAndValidateFooter(
+ footerBytes,
+ segmentId,
+ createdAt,
+ records.isEmpty() ? -1 : records.get(0).getSequence(),
+ records.isEmpty() ? -1 : records.get(records.size() - 1).getSequence(),
+ frameOffset,
+ records.size(),
+ digest.digest(),
+ segment);
+ validLength = reader.getFilePointer();
+ lastCommittedLength = validLength;
+ break;
+ }
+ if (magic != LogicalBackupFormat.RECORD_MAGIC) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_INVALID_LOGICAL_BACKUP_RECORD_MAGIC_AT_OFFSET_ARG_IN_ARG_C132B9D5,
+ frameOffset,
+ segment));
+ }
+ if (fileLength - frameOffset < LogicalBackupFormat.RECORD_HEADER_SIZE) {
+ incompleteTail = true;
+ break;
+ }
+
+ final byte[] header = readBytes(reader, LogicalBackupFormat.RECORD_HEADER_SIZE);
+ final ByteBuffer headerBuffer = wrap(header);
+ headerBuffer.getInt();
+ final byte recordTypeCode = headerBuffer.get();
+ final long sequence = headerBuffer.getLong();
+ final UUID eventGroupId = new UUID(headerBuffer.getLong(), headerBuffer.getLong());
+ final int operationIndex = headerBuffer.getInt();
+ final long eventTime = headerBuffer.getLong();
+ final byte requestVersion = headerBuffer.get();
+ final short requestType = headerBuffer.getShort();
+ final int metadataLength = headerBuffer.getInt();
+ final int payloadLength = headerBuffer.getInt();
+ final int headerCrc = headerBuffer.getInt();
+ if (headerCrc
+ != LogicalBackupFormat.crc32c(header, 0, LogicalBackupFormat.RECORD_HEADER_SIZE - 4)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_RECORD_HEADER_CRC_MISMATCH_AT_OFFSET_ARG_IN_ARG_7076DD79,
+ frameOffset,
+ segment));
+ }
+ if (metadataLength < 0
+ || payloadLength < 0
+ || metadataLength > maxRecordBytes
+ || payloadLength > maxRecordBytes
+ || (long) metadataLength + payloadLength > maxRecordBytes) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_INVALID_LOGICAL_BACKUP_RECORD_LENGTH_AT_OFFSET_ARG_IN_ARG_A7F4048E,
+ frameOffset,
+ segment));
+ }
+ final long remainingLength = (long) metadataLength + payloadLength + Integer.BYTES;
+ if (fileLength - reader.getFilePointer() < remainingLength) {
+ incompleteTail = true;
+ break;
+ }
+
+ final byte[] metadataBytes = readBytes(reader, metadataLength);
+ final byte[] payload = readBytes(reader, payloadLength);
+ final int frameCrc = reader.readInt();
+ final byte[] frameContent = new byte[metadataLength + payloadLength];
+ System.arraycopy(metadataBytes, 0, frameContent, 0, metadataLength);
+ System.arraycopy(payload, 0, frameContent, metadataLength, payloadLength);
+ if (frameCrc != LogicalBackupFormat.crc32c(frameContent, 0, frameContent.length)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_RECORD_PAYLOAD_CRC_MISMATCH_AT_OFFSET_ARG_IN_ARG_F9436552,
+ frameOffset,
+ segment));
+ }
+
+ final LogicalBackupRecordType recordType = LogicalBackupRecordType.valueOf(recordTypeCode);
+ if (recordType == null) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_UNKNOWN_LOGICAL_BACKUP_RECORD_TYPE_ARG_IN_ARG_22BB599D,
+ recordTypeCode,
+ segment));
+ }
+ if (expectedSequence >= 0 && sequence != expectedSequence) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_SEQUENCE_MISMATCH_AT_OFFSET_ARG_IN_ARG_CCEC352B,
+ frameOffset,
+ segment));
+ }
+ final LogicalBackupRecord record =
+ new LogicalBackupRecord(
+ recordType,
+ sequence,
+ eventGroupId,
+ operationIndex,
+ eventTime,
+ requestVersion,
+ requestType,
+ new String(metadataBytes, StandardCharsets.UTF_8),
+ payload);
+ records.add(record);
+ digest.update(header);
+ digest.update(frameContent);
+ digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(frameCrc).array());
+ validLength = reader.getFilePointer();
+ if (recordType == LogicalBackupRecordType.EVENT_BEGIN) {
+ if (openEventGroup || operationIndex != -1) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_NESTED_LOGICAL_BACKUP_EVENT_GROUPS_IN_ARG_896AC47A,
+ segment));
+ }
+ openEventGroup = true;
+ openEventGroupId = eventGroupId;
+ nextOperationIndex = 0;
+ } else if (recordType == LogicalBackupRecordType.EVENT_COMMIT) {
+ if (!openEventGroup) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_COMMIT_WITHOUT_BEGIN_IN_ARG_B9031541,
+ segment));
+ }
+ if (!eventGroupId.equals(openEventGroupId) || operationIndex != nextOperationIndex) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_OPERATION_INDEX_MISMATCH_AT_OFFSET_ARG_IN_ARG_30C3005F,
+ frameOffset,
+ segment));
+ }
+ openEventGroup = false;
+ openEventGroupId = null;
+ lastCommittedLength = validLength;
+ } else if (recordType == LogicalBackupRecordType.PIPE_REQUEST) {
+ if (!openEventGroup) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_REQUEST_OUTSIDE_EVENT_GROUP_8351818F,
+ frameOffset,
+ segment));
+ }
+ if (!eventGroupId.equals(openEventGroupId) || operationIndex != nextOperationIndex) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_OPERATION_INDEX_MISMATCH_AT_OFFSET_ARG_IN_ARG_30C3005F,
+ frameOffset,
+ segment));
+ }
+ nextOperationIndex++;
+ } else if (openEventGroup) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_CONTROL_RECORD_INSIDE_EVENT_GROUP_C45D158B,
+ frameOffset,
+ segment));
+ } else if (!openEventGroup) {
+ lastCommittedLength = validLength;
+ }
+ expectedSequence = sequence + 1;
+ }
+
+ if (incompleteTail && !allowIncompleteTail) {
+ throw new EOFException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_INCOMPLETE_LOGICAL_BACKUP_RECORD_TAIL_ARG_F722DEE7,
+ segment));
+ }
+ if (footer != null && openEventGroup) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_SEALED_LOGICAL_BACKUP_SEGMENT_CONTAINS_AN_OPEN_EVENT_GROUP_ARG_54C274D8,
+ segment));
+ }
+ return new ScanResult(
+ segmentId,
+ createdAt,
+ records,
+ footer,
+ validLength,
+ lastCommittedLength,
+ incompleteTail,
+ openEventGroup);
+ }
+ }
+
+ private static void validateSegmentHeader(final byte[] header, final Path segment)
+ throws IOException {
+ final ByteBuffer buffer = wrap(header);
+ if (buffer.getLong() != LogicalBackupFormat.SEGMENT_MAGIC) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_MAGIC_ARG_8340BA73,
+ segment));
+ }
+ final short majorVersion = buffer.getShort();
+ buffer.getShort();
+ if (majorVersion != LogicalBackupFormat.MAJOR_VERSION) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_SEGMENT_MAJOR_VERSION_ARG_ARG_CE07F1CB,
+ majorVersion,
+ segment));
+ }
+ buffer.position(LogicalBackupFormat.SEGMENT_HEADER_SIZE - Integer.BYTES);
+ final int headerCrc = buffer.getInt();
+ if (headerCrc
+ != LogicalBackupFormat.crc32c(header, 0, LogicalBackupFormat.SEGMENT_HEADER_SIZE - 4)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_SEGMENT_HEADER_CRC_MISMATCH_ARG_07466D55,
+ segment));
+ }
+ }
+
+ private static Footer parseAndValidateFooter(
+ final byte[] bytes,
+ final long expectedSegmentId,
+ final long expectedCreatedAt,
+ final long expectedFirstSequence,
+ final long expectedLastSequence,
+ final long expectedValidBytes,
+ final long expectedRecordCount,
+ final byte[] expectedDigest,
+ final Path segment)
+ throws IOException {
+ final ByteBuffer buffer = wrap(bytes);
+ if (buffer.getInt() != LogicalBackupFormat.FOOTER_MAGIC) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_FOOTER_MAGIC_ARG_D973E2B4,
+ segment));
+ }
+ final long segmentId = buffer.getLong();
+ final long firstSequence = buffer.getLong();
+ final long lastSequence = buffer.getLong();
+ final long recordCount = buffer.getLong();
+ final long validBytes = buffer.getLong();
+ final long createdAt = buffer.getLong();
+ final long sealedAt = buffer.getLong();
+ final byte[] digest = new byte[LogicalBackupFormat.SHA256_SIZE];
+ buffer.get(digest);
+ final int footerCrc = buffer.getInt();
+ if (segmentId != expectedSegmentId) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_ID_MISMATCH_ARG_D832F14D,
+ segment));
+ }
+ if (createdAt != expectedCreatedAt
+ || firstSequence != expectedFirstSequence
+ || lastSequence != expectedLastSequence
+ || validBytes != expectedValidBytes
+ || recordCount != expectedRecordCount) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_METADATA_MISMATCH_ARG_77D1FADB,
+ segment));
+ }
+ if (!Arrays.equals(digest, expectedDigest)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_SEGMENT_DIGEST_MISMATCH_ARG_85346116,
+ segment));
+ }
+ if (footerCrc
+ != LogicalBackupFormat.crc32c(bytes, 0, LogicalBackupFormat.SEGMENT_FOOTER_SIZE - 4)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_SEGMENT_FOOTER_CRC_MISMATCH_ARG_25FB7D91,
+ segment));
+ }
+ return new Footer(
+ segmentId,
+ firstSequence,
+ lastSequence,
+ recordCount,
+ validBytes,
+ createdAt,
+ sealedAt,
+ digest);
+ }
+
+ private static byte[] readBytes(final RandomAccessFile reader, final int length)
+ throws IOException {
+ final byte[] bytes = new byte[length];
+ reader.readFully(bytes);
+ return bytes;
+ }
+
+ private static ByteBuffer wrap(final byte[] bytes) {
+ return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN);
+ }
+
+ public static class ScanResult {
+ private final long segmentId;
+ private final long createdAt;
+ private final List records;
+ private final Footer footer;
+ private final long validLength;
+ private final long lastCommittedLength;
+ private final boolean incompleteTail;
+ private final boolean openEventGroup;
+
+ private ScanResult(
+ final long segmentId,
+ final long createdAt,
+ final List records,
+ final Footer footer,
+ final long validLength,
+ final long lastCommittedLength,
+ final boolean incompleteTail,
+ final boolean openEventGroup) {
+ this.segmentId = segmentId;
+ this.createdAt = createdAt;
+ this.records = Collections.unmodifiableList(records);
+ this.footer = footer;
+ this.validLength = validLength;
+ this.lastCommittedLength = lastCommittedLength;
+ this.incompleteTail = incompleteTail;
+ this.openEventGroup = openEventGroup;
+ }
+
+ public long getSegmentId() {
+ return segmentId;
+ }
+
+ public long getCreatedAt() {
+ return createdAt;
+ }
+
+ public List getRecords() {
+ return records;
+ }
+
+ public Footer getFooter() {
+ return footer;
+ }
+
+ public long getValidLength() {
+ return validLength;
+ }
+
+ public long getLastCommittedLength() {
+ return lastCommittedLength;
+ }
+
+ public boolean hasIncompleteTail() {
+ return incompleteTail;
+ }
+
+ public boolean hasOpenEventGroup() {
+ return openEventGroup;
+ }
+
+ public boolean isSealed() {
+ return footer != null;
+ }
+ }
+
+ public static class Footer {
+ private final long segmentId;
+ private final long firstSequence;
+ private final long lastSequence;
+ private final long recordCount;
+ private final long validBytes;
+ private final long createdAt;
+ private final long sealedAt;
+ private final byte[] digest;
+
+ private Footer(
+ final long segmentId,
+ final long firstSequence,
+ final long lastSequence,
+ final long recordCount,
+ final long validBytes,
+ final long createdAt,
+ final long sealedAt,
+ final byte[] digest) {
+ this.segmentId = segmentId;
+ this.firstSequence = firstSequence;
+ this.lastSequence = lastSequence;
+ this.recordCount = recordCount;
+ this.validBytes = validBytes;
+ this.createdAt = createdAt;
+ this.sealedAt = sealedAt;
+ this.digest = digest;
+ }
+
+ public long getSegmentId() {
+ return segmentId;
+ }
+
+ public long getFirstSequence() {
+ return firstSequence;
+ }
+
+ public long getLastSequence() {
+ return lastSequence;
+ }
+
+ public long getRecordCount() {
+ return recordCount;
+ }
+
+ public long getValidBytes() {
+ return validBytes;
+ }
+
+ public long getCreatedAt() {
+ return createdAt;
+ }
+
+ public long getSealedAt() {
+ return sealedAt;
+ }
+
+ public byte[] getDigest() {
+ return digest;
+ }
+ }
+}
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriter.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriter.java
new file mode 100644
index 000000000000..9c8ba6f9a9a6
--- /dev/null
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriter.java
@@ -0,0 +1,1162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import org.apache.iotdb.commons.i18n.LogicalBackupMessages;
+import org.apache.iotdb.service.rpc.thrift.TPipeTransferReq;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonParseException;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.channels.OverlappingFileLockException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+
+public class LogicalBackupWriter implements Closeable {
+
+ public enum FsyncPolicy {
+ ALWAYS,
+ BATCH,
+ PERIODIC,
+ NONE
+ }
+
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+ private final Path directory;
+ private final Path manifestPath;
+ private final Path lockPath;
+ private final int maxRecordBytes;
+ private final long segmentSizeBytes;
+ private final FsyncPolicy fsyncPolicy;
+ private final int fsyncBatchOperations;
+ private final long fsyncPeriodMs;
+ private final LogicalBackupManifest manifest;
+ private final LogicalBackupSegmentReader reader;
+
+ private FileChannel channel;
+ private FileChannel lockChannel;
+ private FileLock directoryLock;
+ private long segmentId;
+ private long segmentCreatedAt;
+ private long segmentFirstSequence = -1;
+ private long segmentLastSequence = -1;
+ private long segmentRecordCount;
+ private MessageDigest segmentDigest;
+ private long nextSequence;
+ private int operationsSinceFsync;
+ private long lastFsyncAt;
+ private boolean closed;
+ private boolean streamEnded;
+ private boolean recoveredDuringOpen;
+
+ public LogicalBackupWriter(
+ final Path directory,
+ final LogicalBackupManifest manifest,
+ final long segmentSizeBytes,
+ final int maxRecordBytes,
+ final FsyncPolicy fsyncPolicy,
+ final int fsyncBatchOperations,
+ final long fsyncPeriodMs,
+ final boolean append)
+ throws IOException {
+ this.directory =
+ Objects.requireNonNull(directory, LogicalBackupMessages.EXCEPTION_DIRECTORY_5F8F22B8)
+ .toAbsolutePath()
+ .normalize();
+ this.manifest =
+ Objects.requireNonNull(manifest, LogicalBackupMessages.EXCEPTION_MANIFEST_7F5CB74A);
+ this.segmentSizeBytes = segmentSizeBytes;
+ this.maxRecordBytes = maxRecordBytes;
+ this.fsyncPolicy =
+ Objects.requireNonNull(fsyncPolicy, LogicalBackupMessages.EXCEPTION_FSYNC_POLICY_6D493614);
+ if (segmentSizeBytes
+ <= LogicalBackupFormat.SEGMENT_HEADER_SIZE + LogicalBackupFormat.SEGMENT_FOOTER_SIZE
+ || maxRecordBytes <= 0
+ || maxRecordBytes > LogicalBackupFormat.MAX_RECORD_BYTES
+ || fsyncBatchOperations <= 0
+ || fsyncPeriodMs <= 0) {
+ throw new IOException(
+ LogicalBackupMessages.EXCEPTION_INVALID_LOGICAL_BACKUP_WRITER_CONFIGURATION_707BCC99);
+ }
+ this.fsyncBatchOperations = fsyncBatchOperations;
+ this.fsyncPeriodMs = fsyncPeriodMs;
+ this.manifestPath = this.directory.resolve(LogicalBackupFormat.MANIFEST_FILE_NAME);
+ this.lockPath = this.directory.resolve(".backup.lock");
+ this.reader = new LogicalBackupSegmentReader(maxRecordBytes);
+ try {
+ prepareDirectory(append);
+ acquireLock();
+ if (Files.isSymbolicLink(manifestPath)) {
+ throw symbolicLinkException(manifestPath);
+ }
+ if (Files.exists(manifestPath)) {
+ if (!append) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_DIRECTORY_ALREADY_EXISTS_ARG_1C521D1D,
+ directory));
+ }
+ loadExistingManifest();
+ } else {
+ manifest.status = "WRITING";
+ manifest.createdAt = Instant.now().toString();
+ manifest.fsyncPolicy = fsyncPolicy.name().toLowerCase(Locale.ROOT);
+ manifest.segmentSizeBytes = segmentSizeBytes;
+ manifest.maxRecordBytes = maxRecordBytes;
+ writeManifest();
+ }
+ manifest.status = manifest.recovered ? "RECOVERED" : "WRITING";
+ manifest.closedAt = null;
+ nextSequence = Math.max(0, manifest.lastSequence + 1);
+ openOrCreateSegment();
+ rebuildManifestFromSegments();
+ if (recoveredDuringOpen) {
+ manifest.lastDurableSequence = manifest.lastSequence;
+ } else {
+ manifest.lastDurableSequence =
+ Math.max(-1, Math.min(manifest.lastDurableSequence, manifest.lastSequence));
+ }
+ updateActiveSegmentManifest();
+ manifest.status = manifest.recovered ? "RECOVERED" : "WRITING";
+ manifest.closedAt = null;
+ writeManifest();
+ lastFsyncAt = System.currentTimeMillis();
+ } catch (final IOException | RuntimeException e) {
+ if (channel != null) {
+ try {
+ channel.close();
+ } catch (final IOException closeException) {
+ e.addSuppressed(closeException);
+ }
+ channel = null;
+ }
+ releaseLock();
+ throw e;
+ }
+ }
+
+ private void prepareDirectory(final boolean append) throws IOException {
+ rejectSymbolicLinksInDirectoryPath();
+ if (append) {
+ Files.createDirectories(directory);
+ rejectSymbolicLinksInDirectoryPath();
+ return;
+ }
+ final Path parent = directory.getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ rejectSymbolicLinksInDirectoryPath();
+ }
+ try {
+ Files.createDirectory(directory);
+ } catch (final FileAlreadyExistsException e) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_DIRECTORY_ALREADY_EXISTS_ARG_1C521D1D,
+ directory),
+ e);
+ }
+ rejectSymbolicLinksInDirectoryPath();
+ }
+
+ private void rejectSymbolicLinksInDirectoryPath() throws IOException {
+ Path current = directory.getRoot();
+ for (final Path part : directory) {
+ current = current == null ? part : current.resolve(part);
+ if (Files.isSymbolicLink(current)) {
+ throw symbolicLinkException(current);
+ }
+ }
+ }
+
+ private static IOException symbolicLinkException(final Path path) {
+ return new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_SYMBOLIC_LINKS_ARE_NOT_ALLOWED_IN_LOGICAL_BACKUP_DIRECTORY_PATHS_ARG_7E428569,
+ path));
+ }
+
+ public synchronized long writeEvent(
+ final UUID eventGroupId,
+ final long eventTime,
+ final List requests,
+ final String metadata)
+ throws IOException {
+ ensureOpen();
+ if (requests == null || requests.isEmpty()) {
+ throw new IOException(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_EVENT_MUST_CONTAIN_AT_LEAST_ONE_REQUEST_0C278BAC);
+ }
+ Objects.requireNonNull(eventGroupId, LogicalBackupMessages.EXCEPTION_EVENT_GROUP_ID_C6F6268A);
+ final String safeMetadata = metadata == null ? "" : metadata;
+ final String eventDigest = computeEventDigest(requests);
+ if (eventGroupId.toString().equals(manifest.lastEventGroupId)) {
+ if (eventDigest.equals(manifest.lastEventDigest)) {
+ updateActiveSegmentManifest();
+ writeManifest();
+ return manifest.lastEventFirstSequence;
+ }
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_EVENT_ID_ARG_WAS_ALREADY_WRITTEN_WITH_A_DIFFERENT_DIGEST_6A297330,
+ eventGroupId));
+ }
+ rollBeforeEventIfNecessary(requests, safeMetadata);
+ final long eventStartPosition = channel.position();
+ final long firstSequence = nextSequence;
+ try {
+ appendRecord(
+ LogicalBackupRecordType.EVENT_BEGIN, eventGroupId, -1, eventTime, null, safeMetadata);
+ int operationIndex = 0;
+ for (final TPipeTransferReq request : requests) {
+ if (request == null || request.getBody() == null) {
+ throw new IOException(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_REQUEST_BODY_MUST_NOT_BE_NULL_EFFD92D9);
+ }
+ appendRecord(
+ LogicalBackupRecordType.PIPE_REQUEST,
+ eventGroupId,
+ operationIndex++,
+ eventTime,
+ request,
+ safeMetadata);
+ }
+ appendRecord(
+ LogicalBackupRecordType.EVENT_COMMIT,
+ eventGroupId,
+ operationIndex,
+ eventTime,
+ null,
+ safeMetadata);
+ } catch (final IOException | RuntimeException e) {
+ try {
+ rollbackTo(eventStartPosition);
+ } catch (final IOException rollbackException) {
+ e.addSuppressed(rollbackException);
+ }
+ throw e;
+ }
+ if (forceIfNeeded()) {
+ manifest.lastDurableSequence = manifest.lastSequence;
+ }
+ updateActiveSegmentManifest();
+ manifest.lastEventGroupId = eventGroupId.toString();
+ manifest.lastEventDigest = eventDigest;
+ manifest.lastEventFirstSequence = firstSequence;
+ writeManifest();
+ return firstSequence;
+ }
+
+ public synchronized void writeControl(
+ final LogicalBackupRecordType recordType, final long eventTime, final String metadata)
+ throws IOException {
+ ensureOpen();
+ final String safeMetadata = metadata == null ? "" : metadata;
+ rollBeforeControlIfNecessary(safeMetadata);
+ final long recordStartPosition = channel.position();
+ try {
+ appendRecord(recordType, new UUID(0, 0), -1, eventTime, null, safeMetadata);
+ } catch (final IOException | RuntimeException e) {
+ try {
+ rollbackTo(recordStartPosition);
+ } catch (final IOException rollbackException) {
+ e.addSuppressed(rollbackException);
+ }
+ throw e;
+ }
+ if (forceIfNeeded()) {
+ manifest.lastDurableSequence = manifest.lastSequence;
+ }
+ updateActiveSegmentManifest();
+ writeManifest();
+ }
+
+ public synchronized void recordSkippedEvent(final long eventTime, final String metadata)
+ throws IOException {
+ writeControl(LogicalBackupRecordType.SKIPPED_EVENT, eventTime, metadata);
+ manifest.skippedEventCount++;
+ writeManifest();
+ }
+
+ public synchronized LogicalBackupManifest getManifest() {
+ return manifest;
+ }
+
+ public synchronized void heartbeat() throws IOException {
+ ensureOpen();
+ if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)
+ || directoryLock == null
+ || !directoryLock.isValid()) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_DIRECTORY_IS_UNAVAILABLE_ARG_85F090AD,
+ directory));
+ }
+ if (forceIfNeeded()) {
+ manifest.lastDurableSequence = manifest.lastSequence;
+ updateActiveSegmentManifest();
+ writeManifest();
+ }
+ }
+
+ private void appendRecord(
+ final LogicalBackupRecordType recordType,
+ final UUID eventGroupId,
+ final int operationIndex,
+ final long eventTime,
+ final TPipeTransferReq request,
+ final String metadata)
+ throws IOException {
+ final byte[] metadataBytes = metadata.getBytes(StandardCharsets.UTF_8);
+ final byte[] payload =
+ request == null
+ ? new byte[0]
+ : java.util.Arrays.copyOf(request.getBody(), request.getBody().length);
+ if ((long) metadataBytes.length + payload.length > maxRecordBytes) {
+ throw new IOException(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_RECORD_EXCEEDS_MAX_RECORD_BYTES_B9A9C996);
+ }
+ final long requiredBytes =
+ LogicalBackupFormat.RECORD_HEADER_SIZE
+ + metadataBytes.length
+ + payload.length
+ + Integer.BYTES;
+ final long sequence = nextSequence++;
+ final ByteBuffer header =
+ ByteBuffer.allocate(LogicalBackupFormat.RECORD_HEADER_SIZE).order(ByteOrder.BIG_ENDIAN);
+ header.putInt(LogicalBackupFormat.RECORD_MAGIC);
+ header.put(recordType.getCode());
+ header.putLong(sequence);
+ header.putLong(eventGroupId.getMostSignificantBits());
+ header.putLong(eventGroupId.getLeastSignificantBits());
+ header.putInt(operationIndex);
+ header.putLong(eventTime);
+ header.put(request == null ? (byte) 0 : request.getVersion());
+ header.putShort(request == null ? (short) 0 : request.getType());
+ header.putInt(metadataBytes.length);
+ header.putInt(payload.length);
+ header.putInt(0);
+ final byte[] headerBytes = header.array();
+ final int headerCrc =
+ LogicalBackupFormat.crc32c(headerBytes, 0, LogicalBackupFormat.RECORD_HEADER_SIZE - 4);
+ ByteBuffer.wrap(headerBytes)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putInt(LogicalBackupFormat.RECORD_HEADER_SIZE - 4, headerCrc);
+ final byte[] frameContent = new byte[metadataBytes.length + payload.length];
+ System.arraycopy(metadataBytes, 0, frameContent, 0, metadataBytes.length);
+ System.arraycopy(payload, 0, frameContent, metadataBytes.length, payload.length);
+ final int frameCrc = LogicalBackupFormat.crc32c(frameContent, 0, frameContent.length);
+
+ writeFully(ByteBuffer.wrap(headerBytes));
+ writeFully(ByteBuffer.wrap(frameContent));
+ writeFully(
+ ByteBuffer.allocate(Integer.BYTES).order(ByteOrder.BIG_ENDIAN).putInt(frameCrc).flip());
+ segmentDigest.update(headerBytes);
+ segmentDigest.update(frameContent);
+ segmentDigest.update(
+ ByteBuffer.allocate(Integer.BYTES).order(ByteOrder.BIG_ENDIAN).putInt(frameCrc).array());
+ if (segmentFirstSequence < 0) {
+ segmentFirstSequence = sequence;
+ }
+ segmentLastSequence = sequence;
+ segmentRecordCount++;
+ manifest.firstSequence = manifest.firstSequence < 0 ? sequence : manifest.firstSequence;
+ manifest.lastSequence = sequence;
+ final String countKey = recordType.name();
+ manifest.operationCounts.put(countKey, manifest.operationCounts.getOrDefault(countKey, 0L) + 1);
+ operationsSinceFsync++;
+ streamEnded = recordType == LogicalBackupRecordType.STREAM_END;
+ }
+
+ private void openOrCreateSegment() throws IOException {
+ if (manifest.segments.isEmpty()) {
+ segmentId = 0;
+ validateSegmentFiles(segmentFileName(segmentId));
+ openNewSegment();
+ return;
+ }
+ final LogicalBackupManifest.Segment last = manifest.segments.get(manifest.segments.size() - 1);
+ final Path segmentPath = resolveExistingSegmentPath(last.file);
+ LogicalBackupSegmentReader.ScanResult scan = scanLastSegmentForAppend(segmentPath, last);
+ if (scan.isSealed()) {
+ if (!"SEALED".equals(last.status)) {
+ manifest.recovered = true;
+ recoveredDuringOpen = true;
+ }
+ segmentId = scan.getSegmentId() + 1;
+ // Validate every existing sealed segment before creating a new file in the backup.
+ rebuildManifestFromSegments();
+ validateSegmentFiles(segmentFileName(segmentId));
+ openNewSegment();
+ return;
+ }
+ validateSegmentFiles(null);
+ if (scan.hasIncompleteTail() || scan.hasOpenEventGroup()) {
+ try (final FileChannel truncateChannel =
+ FileChannel.open(segmentPath, StandardOpenOption.WRITE)) {
+ truncateChannel.truncate(scan.getLastCommittedLength());
+ truncateChannel.force(true);
+ }
+ manifest.recovered = true;
+ manifest.status = "RECOVERED";
+ recoveredDuringOpen = true;
+ scan = reader.scan(segmentPath, false);
+ }
+ if (!scan.getRecords().isEmpty()) {
+ nextSequence = scan.getRecords().get(scan.getRecords().size() - 1).getSequence() + 1;
+ manifest.lastSequence = scan.getRecords().get(scan.getRecords().size() - 1).getSequence();
+ }
+ segmentId = scan.getSegmentId();
+ openExistingSegment(segmentPath, scan);
+ }
+
+ private LogicalBackupSegmentReader.ScanResult scanLastSegmentForAppend(
+ final Path segmentPath, final LogicalBackupManifest.Segment segment) throws IOException {
+ try {
+ return reader.scan(segmentPath, true);
+ } catch (final IOException scanException) {
+ final long recoverableLength =
+ "SEALED".equals(segment.status)
+ ? -1
+ : findRecoverableTailStart(segmentPath, segment.sizeBytes);
+ if (recoverableLength < LogicalBackupFormat.SEGMENT_HEADER_SIZE) {
+ throw scanException;
+ }
+ try (final FileChannel truncateChannel =
+ FileChannel.open(segmentPath, StandardOpenOption.WRITE)) {
+ truncateChannel.truncate(recoverableLength);
+ truncateChannel.force(true);
+ }
+ manifest.recovered = true;
+ manifest.status = "RECOVERED";
+ recoveredDuringOpen = true;
+ return reader.scan(segmentPath, false);
+ }
+ }
+
+ private static long findRecoverableTailStart(
+ final Path segmentPath, final long manifestSegmentSize) throws IOException {
+ final long fileSize = Files.size(segmentPath);
+ if (fileSize
+ >= LogicalBackupFormat.SEGMENT_HEADER_SIZE + LogicalBackupFormat.SEGMENT_FOOTER_SIZE) {
+ final ByteBuffer magic = ByteBuffer.allocate(Integer.BYTES).order(ByteOrder.BIG_ENDIAN);
+ try (final FileChannel readChannel = FileChannel.open(segmentPath, StandardOpenOption.READ)) {
+ readChannel.position(fileSize - LogicalBackupFormat.SEGMENT_FOOTER_SIZE);
+ while (magic.hasRemaining()) {
+ if (readChannel.read(magic) < 0) {
+ break;
+ }
+ }
+ }
+ if (!magic.hasRemaining() && magic.flip().getInt() == LogicalBackupFormat.FOOTER_MAGIC) {
+ return fileSize - LogicalBackupFormat.SEGMENT_FOOTER_SIZE;
+ }
+ }
+ if (manifestSegmentSize >= LogicalBackupFormat.SEGMENT_HEADER_SIZE
+ && manifestSegmentSize < fileSize
+ && fileSize - manifestSegmentSize <= LogicalBackupFormat.SEGMENT_FOOTER_SIZE) {
+ return manifestSegmentSize;
+ }
+ return -1;
+ }
+
+ private void openExistingSegment(
+ final Path segmentPath, final LogicalBackupSegmentReader.ScanResult scan) throws IOException {
+ channel = FileChannel.open(segmentPath, StandardOpenOption.WRITE, StandardOpenOption.READ);
+ channel.position(channel.size());
+ segmentCreatedAt = scan.getCreatedAt();
+ segmentDigest = LogicalBackupFormat.newSha256();
+ try (final FileChannel digestChannel = FileChannel.open(segmentPath, StandardOpenOption.READ)) {
+ final ByteBuffer digestBuffer = ByteBuffer.allocate(64 * 1024);
+ long remaining = scan.getValidLength();
+ while (remaining > 0) {
+ digestBuffer.clear();
+ digestBuffer.limit((int) Math.min(digestBuffer.capacity(), remaining));
+ final int read = digestChannel.read(digestBuffer);
+ if (read < 0) {
+ break;
+ }
+ remaining -= read;
+ segmentDigest.update(digestBuffer.array(), 0, read);
+ }
+ }
+ segmentFirstSequence = -1;
+ segmentLastSequence = -1;
+ segmentRecordCount = 0;
+ for (final LogicalBackupRecord record : scan.getRecords()) {
+ segmentFirstSequence = segmentFirstSequence < 0 ? record.getSequence() : segmentFirstSequence;
+ segmentLastSequence = record.getSequence();
+ segmentRecordCount++;
+ }
+ streamEnded =
+ !scan.getRecords().isEmpty()
+ && scan.getRecords().get(scan.getRecords().size() - 1).getRecordType()
+ == LogicalBackupRecordType.STREAM_END;
+ }
+
+ private void openNewSegment() throws IOException {
+ final String fileName = segmentFileName(segmentId);
+ final Path segmentPath = resolveSegmentPath(fileName);
+ final Path temporarySegmentPath = resolveSegmentPath(fileName + ".tmp");
+ if (Files.exists(segmentPath, LinkOption.NOFOLLOW_LINKS)) {
+ adoptOrphanSegment(resolveExistingSegmentPath(fileName), fileName);
+ return;
+ }
+
+ // A temporary segment is never exposed through the manifest and contains only a header, so it
+ // is safe to discard after an interrupted creation attempt.
+ Files.deleteIfExists(temporarySegmentPath);
+ segmentCreatedAt = System.currentTimeMillis();
+ final ByteBuffer header =
+ ByteBuffer.allocate(LogicalBackupFormat.SEGMENT_HEADER_SIZE).order(ByteOrder.BIG_ENDIAN);
+ header.putLong(LogicalBackupFormat.SEGMENT_MAGIC);
+ header.putShort(LogicalBackupFormat.MAJOR_VERSION);
+ header.putShort(LogicalBackupFormat.MINOR_VERSION);
+ header.putLong(segmentId);
+ header.putLong(segmentCreatedAt);
+ header.putInt(0);
+ final byte[] bytes = header.array();
+ ByteBuffer.wrap(bytes)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putInt(
+ LogicalBackupFormat.SEGMENT_HEADER_SIZE - 4,
+ LogicalBackupFormat.crc32c(bytes, 0, LogicalBackupFormat.SEGMENT_HEADER_SIZE - 4));
+ try (final FileChannel temporaryChannel =
+ FileChannel.open(
+ temporarySegmentPath,
+ StandardOpenOption.CREATE_NEW,
+ StandardOpenOption.WRITE,
+ StandardOpenOption.READ)) {
+ final ByteBuffer headerBuffer = ByteBuffer.wrap(bytes);
+ while (headerBuffer.hasRemaining()) {
+ temporaryChannel.write(headerBuffer);
+ }
+ temporaryChannel.force(true);
+ }
+ try {
+ Files.move(temporarySegmentPath, segmentPath, StandardCopyOption.ATOMIC_MOVE);
+ } catch (final AtomicMoveNotSupportedException e) {
+ Files.move(temporarySegmentPath, segmentPath);
+ }
+
+ final LogicalBackupSegmentReader.ScanResult scan = reader.scan(segmentPath, false);
+ openExistingSegment(segmentPath, scan);
+ addActiveSegmentToManifest(fileName);
+ }
+
+ private void adoptOrphanSegment(final Path segmentPath, final String fileName)
+ throws IOException {
+ final LogicalBackupSegmentReader.ScanResult scan = reader.scan(segmentPath, false);
+ if (scan.getSegmentId() != segmentId
+ || scan.isSealed()
+ || !scan.getRecords().isEmpty()
+ || scan.getValidLength() != LogicalBackupFormat.SEGMENT_HEADER_SIZE) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_UNLISTED_LOGICAL_BACKUP_SEGMENT_ARG_FF548C0B,
+ segmentPath));
+ }
+ openExistingSegment(segmentPath, scan);
+ addActiveSegmentToManifest(fileName);
+ }
+
+ private void addActiveSegmentToManifest(final String fileName) throws IOException {
+ final LogicalBackupManifest.Segment segment = new LogicalBackupManifest.Segment();
+ segment.file = fileName;
+ segment.segmentId = segmentId;
+ segment.status = "ACTIVE";
+ manifest.segments.add(segment);
+ writeManifest();
+ }
+
+ private void validateSegmentFiles(final String allowedOrphanFile) throws IOException {
+ final Set listedFiles = new HashSet<>();
+ for (final LogicalBackupManifest.Segment segment : manifest.segments) {
+ listedFiles.add(segment.file);
+ }
+ try (final DirectoryStream segmentFiles = Files.newDirectoryStream(directory, "*.pwal")) {
+ for (final Path segmentFile : segmentFiles) {
+ final String fileName = segmentFile.getFileName().toString();
+ if (!listedFiles.contains(fileName) && !fileName.equals(allowedOrphanFile)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_UNLISTED_LOGICAL_BACKUP_SEGMENT_ARG_FF548C0B,
+ segmentFile));
+ }
+ }
+ }
+ }
+
+ private static String segmentFileName(final long id) {
+ return String.format(Locale.ROOT, "segment-%020d.pwal", id);
+ }
+
+ private void sealSegment() throws IOException {
+ if (channel == null || segmentRecordCount == 0) {
+ return;
+ }
+ final byte[] digest = segmentDigest.digest();
+ final ByteBuffer footer =
+ ByteBuffer.allocate(LogicalBackupFormat.SEGMENT_FOOTER_SIZE).order(ByteOrder.BIG_ENDIAN);
+ footer.putInt(LogicalBackupFormat.FOOTER_MAGIC);
+ footer.putLong(segmentId);
+ footer.putLong(segmentFirstSequence);
+ footer.putLong(segmentLastSequence);
+ footer.putLong(segmentRecordCount);
+ footer.putLong(channel.position());
+ footer.putLong(segmentCreatedAt);
+ footer.putLong(System.currentTimeMillis());
+ footer.put(digest);
+ footer.putInt(0);
+ final byte[] footerBytes = footer.array();
+ ByteBuffer.wrap(footerBytes)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putInt(
+ LogicalBackupFormat.SEGMENT_FOOTER_SIZE - 4,
+ LogicalBackupFormat.crc32c(
+ footerBytes, 0, LogicalBackupFormat.SEGMENT_FOOTER_SIZE - 4));
+ writeFully(ByteBuffer.wrap(footerBytes));
+ channel.force(true);
+ final LogicalBackupManifest.Segment segment =
+ manifest.segments.get(manifest.segments.size() - 1);
+ segment.firstSequence = segmentFirstSequence;
+ segment.lastSequence = segmentLastSequence;
+ segment.recordCount = segmentRecordCount;
+ segment.sizeBytes = channel.position();
+ segment.sha256 = LogicalBackupFormat.toHex(digest);
+ segment.status = "SEALED";
+ manifest.lastDurableSequence = segmentLastSequence;
+ channel.close();
+ channel = null;
+ writeManifest();
+ }
+
+ private boolean forceIfNeeded() throws IOException {
+ if (channel == null) {
+ return false;
+ }
+ final long now = System.currentTimeMillis();
+ final boolean shouldForce =
+ fsyncPolicy == FsyncPolicy.ALWAYS
+ || (fsyncPolicy == FsyncPolicy.BATCH && operationsSinceFsync >= fsyncBatchOperations)
+ || (fsyncPolicy == FsyncPolicy.PERIODIC && now - lastFsyncAt >= fsyncPeriodMs);
+ if (shouldForce) {
+ channel.force(true);
+ operationsSinceFsync = 0;
+ lastFsyncAt = now;
+ }
+ return shouldForce;
+ }
+
+ private void writeManifest() throws IOException {
+ final Path temporary =
+ manifestPath.resolveSibling(LogicalBackupFormat.MANIFEST_FILE_NAME + ".tmp");
+ final byte[] manifestBytes = GSON.toJson(manifest).getBytes(StandardCharsets.UTF_8);
+ Files.deleteIfExists(temporary);
+ try (final FileChannel manifestChannel =
+ FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
+ final ByteBuffer manifestBuffer = ByteBuffer.wrap(manifestBytes);
+ while (manifestBuffer.hasRemaining()) {
+ manifestChannel.write(manifestBuffer);
+ }
+ manifestChannel.force(true);
+ }
+ try {
+ Files.move(
+ temporary,
+ manifestPath,
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING);
+ } catch (final AtomicMoveNotSupportedException e) {
+ Files.move(temporary, manifestPath, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ private void loadExistingManifest() throws IOException {
+ final LogicalBackupManifest existing;
+ try {
+ existing =
+ GSON.fromJson(
+ Files.readString(manifestPath, StandardCharsets.UTF_8), LogicalBackupManifest.class);
+ } catch (final JsonParseException | NullPointerException e) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_MANIFEST_IS_INVALID_ARG_CB809CC7,
+ manifestPath),
+ e);
+ }
+ if (existing == null) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_MANIFEST_IS_INVALID_ARG_CB809CC7,
+ manifestPath));
+ }
+ if (!LogicalBackupFormat.FORMAT_NAME.equals(existing.formatName)
+ || !LogicalBackupFormat.FORMAT_VERSION.equals(existing.formatVersion)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_MANIFEST_FORMAT_ARG_5005F99E,
+ manifestPath));
+ }
+ validateManifestIdentity("backupId", manifest.backupId, existing.backupId);
+ validateManifestIdentity("streamId", manifest.streamId, existing.streamId);
+ validateManifestIdentity("pipeName", manifest.pipeName, existing.pipeName);
+ validateManifestIdentity("streamType", manifest.streamType, existing.streamType);
+ validateManifestIdentity("sourceClusterId", manifest.sourceClusterId, existing.sourceClusterId);
+ validateManifestIdentity("sourceVersion", manifest.sourceVersion, existing.sourceVersion);
+ validateManifestIdentity(
+ "timestampPrecision", manifest.timestampPrecision, existing.timestampPrecision);
+ validateManifestIdentity("sinkTaskId", manifest.sinkTaskId, existing.sinkTaskId);
+ validateManifestIdentity(
+ "fsyncPolicy", fsyncPolicy.name().toLowerCase(Locale.ROOT), existing.fsyncPolicy);
+ validateManifestConfiguration("segmentSizeBytes", segmentSizeBytes, existing.segmentSizeBytes);
+ validateManifestConfiguration("maxRecordBytes", maxRecordBytes, existing.maxRecordBytes);
+ if (manifest.pipeCreationTime != 0 && manifest.pipeCreationTime != existing.pipeCreationTime) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_MANIFEST_DOES_NOT_MATCH_ARG_EXPECTED_ARG_FOUND_ARG_D7BC8AD1,
+ "pipeCreationTime",
+ manifest.pipeCreationTime,
+ existing.pipeCreationTime));
+ }
+ if (manifest.regionId != existing.regionId) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_MANIFEST_DOES_NOT_MATCH_ARG_EXPECTED_ARG_FOUND_ARG_D7BC8AD1,
+ "regionId",
+ manifest.regionId,
+ existing.regionId));
+ }
+ manifest.status = existing.status;
+ manifest.backupId = existing.backupId;
+ manifest.pipeName = existing.pipeName;
+ manifest.pipeCreationTime = existing.pipeCreationTime;
+ manifest.sourceClusterId = existing.sourceClusterId;
+ manifest.sourceVersion = existing.sourceVersion;
+ manifest.timestampPrecision = existing.timestampPrecision;
+ manifest.streamId = existing.streamId;
+ manifest.regionId = existing.regionId;
+ manifest.sinkTaskId = existing.sinkTaskId;
+ manifest.streamType = existing.streamType;
+ manifest.firstSequence = existing.firstSequence;
+ manifest.lastSequence = existing.lastSequence;
+ manifest.lastDurableSequence = existing.lastDurableSequence;
+ manifest.recovered = existing.recovered;
+ manifest.segments = existing.segments == null ? new ArrayList<>() : existing.segments;
+ manifest.operationCounts =
+ existing.operationCounts == null
+ ? new java.util.LinkedHashMap<>()
+ : existing.operationCounts;
+ manifest.createdAt = existing.createdAt;
+ manifest.closedAt = existing.closedAt;
+ manifest.fsyncPolicy = existing.fsyncPolicy;
+ manifest.segmentSizeBytes = existing.segmentSizeBytes;
+ manifest.maxRecordBytes = existing.maxRecordBytes;
+ manifest.lastEventGroupId = existing.lastEventGroupId;
+ manifest.lastEventDigest = existing.lastEventDigest;
+ manifest.lastEventFirstSequence = existing.lastEventFirstSequence;
+ manifest.skippedEventCount = existing.skippedEventCount;
+ }
+
+ private void validateManifestIdentity(
+ final String field, final String expected, final String actual) throws IOException {
+ if (expected != null && !Objects.equals(expected, actual)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_MANIFEST_DOES_NOT_MATCH_ARG_EXPECTED_ARG_FOUND_ARG_D7BC8AD1,
+ field,
+ expected,
+ actual));
+ }
+ }
+
+ private void validateManifestConfiguration(
+ final String field, final long expected, final long actual) throws IOException {
+ if (expected != actual) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_MANIFEST_DOES_NOT_MATCH_ARG_EXPECTED_ARG_FOUND_ARG_D7BC8AD1,
+ field,
+ expected,
+ actual));
+ }
+ }
+
+ private void acquireLock() throws IOException {
+ try {
+ if (Files.isSymbolicLink(lockPath)) {
+ throw symbolicLinkException(lockPath);
+ }
+ lockChannel = FileChannel.open(lockPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
+ directoryLock = lockChannel.tryLock();
+ if (directoryLock == null) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_DIRECTORY_IS_LOCKED_ARG_A4366800,
+ directory));
+ }
+ lockChannel.truncate(0);
+ lockChannel.write(
+ StandardCharsets.UTF_8.encode(Long.toString(ProcessHandle.current().pid())));
+ lockChannel.force(true);
+ } catch (final OverlappingFileLockException e) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_DIRECTORY_IS_LOCKED_ARG_A4366800,
+ directory),
+ e);
+ }
+ }
+
+ private void releaseLock() {
+ try {
+ if (directoryLock != null) {
+ directoryLock.release();
+ directoryLock = null;
+ }
+ } catch (final IOException ignored) {
+ // Best effort during failure/close.
+ }
+ try {
+ if (lockChannel != null) {
+ lockChannel.close();
+ lockChannel = null;
+ }
+ } catch (final IOException ignored) {
+ // Best effort during failure/close.
+ }
+ }
+
+ private void writeFully(final ByteBuffer buffer) throws IOException {
+ while (buffer.hasRemaining()) {
+ channel.write(buffer);
+ }
+ }
+
+ private void ensureOpen() throws IOException {
+ if (closed || channel == null) {
+ throw new IOException(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_WRITER_IS_CLOSED_EE463BBB);
+ }
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ if (closed) {
+ return;
+ }
+ try {
+ if (channel != null) {
+ if (!streamEnded) {
+ writeControl(LogicalBackupRecordType.STREAM_END, System.currentTimeMillis(), "");
+ }
+ sealSegment();
+ }
+ manifest.status = manifest.recovered ? "RECOVERED" : "SEALED";
+ manifest.closedAt = Instant.now().toString();
+ manifest.lastDurableSequence = manifest.lastSequence;
+ writeManifest();
+ } finally {
+ if (channel != null) {
+ channel.close();
+ channel = null;
+ }
+ closed = true;
+ releaseLock();
+ }
+ }
+
+ private void rollBeforeEventIfNecessary(
+ final List requests, final String metadata) throws IOException {
+ final long metadataBytes = metadata.getBytes(StandardCharsets.UTF_8).length;
+ long requiredBytes = frameSize(metadataBytes, 0) * 2;
+ for (final TPipeTransferReq request : requests) {
+ if (request == null || request.getBody() == null) {
+ throw new IOException(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_REQUEST_BODY_MUST_NOT_BE_NULL_EFFD92D9);
+ }
+ requiredBytes += frameSize(metadataBytes, request.getBody().length);
+ }
+ if (channel.position() > LogicalBackupFormat.SEGMENT_HEADER_SIZE
+ && channel.position() + requiredBytes + LogicalBackupFormat.SEGMENT_FOOTER_SIZE
+ > segmentSizeBytes) {
+ sealSegment();
+ segmentId++;
+ openNewSegment();
+ }
+ }
+
+ private void rollBeforeControlIfNecessary(final String metadata) throws IOException {
+ final long requiredBytes = frameSize(metadata.getBytes(StandardCharsets.UTF_8).length, 0);
+ if (channel.position() > LogicalBackupFormat.SEGMENT_HEADER_SIZE
+ && channel.position() + requiredBytes + LogicalBackupFormat.SEGMENT_FOOTER_SIZE
+ > segmentSizeBytes) {
+ sealSegment();
+ segmentId++;
+ openNewSegment();
+ }
+ }
+
+ private static long frameSize(final long metadataBytes, final long payloadBytes) {
+ return LogicalBackupFormat.RECORD_HEADER_SIZE + metadataBytes + payloadBytes + Integer.BYTES;
+ }
+
+ private static String computeEventDigest(final List requests)
+ throws IOException {
+ final MessageDigest digest = LogicalBackupFormat.newSha256();
+ for (final TPipeTransferReq request : requests) {
+ if (request == null || request.getBody() == null) {
+ throw new IOException(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_REQUEST_BODY_MUST_NOT_BE_NULL_EFFD92D9);
+ }
+ updateEventDigest(digest, request.getVersion(), request.getType(), request.getBody());
+ }
+ return LogicalBackupFormat.toHex(digest.digest());
+ }
+
+ private static void updateEventDigest(
+ final MessageDigest digest,
+ final byte requestVersion,
+ final short requestType,
+ final byte[] body) {
+ digest.update(requestVersion);
+ digest.update(
+ ByteBuffer.allocate(Short.BYTES).order(ByteOrder.BIG_ENDIAN).putShort(requestType).array());
+ digest.update(body);
+ }
+
+ private void rollbackTo(final long position) throws IOException {
+ final Path segmentPath = currentSegmentPath();
+ channel.truncate(position);
+ channel.force(true);
+ channel.close();
+ channel = null;
+ final LogicalBackupSegmentReader.ScanResult scan = reader.scan(segmentPath, false);
+ openExistingSegment(segmentPath, scan);
+ rebuildManifestFromSegments();
+ manifest.recovered = true;
+ manifest.status = "RECOVERED";
+ manifest.lastDurableSequence = manifest.lastSequence;
+ operationsSinceFsync = 0;
+ lastFsyncAt = System.currentTimeMillis();
+ updateActiveSegmentManifest();
+ writeManifest();
+ }
+
+ private void rebuildManifestFromSegments() throws IOException {
+ manifest.firstSequence = -1;
+ manifest.lastSequence = -1;
+ manifest.lastEventGroupId = null;
+ manifest.lastEventDigest = null;
+ manifest.lastEventFirstSequence = -1;
+ manifest.skippedEventCount = 0;
+ manifest.operationCounts = new LinkedHashMap<>();
+
+ long expectedSequence = -1;
+ long expectedSegmentId = -1;
+ for (int segmentIndex = 0; segmentIndex < manifest.segments.size(); segmentIndex++) {
+ final LogicalBackupManifest.Segment segment = manifest.segments.get(segmentIndex);
+ final Path segmentPath = resolveExistingSegmentPath(segment.file);
+ final LogicalBackupSegmentReader.ScanResult scan = reader.scan(segmentPath, false);
+ if (scan.getSegmentId() != segment.segmentId) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_SEGMENT_ID_MISMATCH_ARG_9FE7E88A,
+ segmentPath));
+ }
+ if ((expectedSegmentId >= 0 && scan.getSegmentId() != expectedSegmentId)
+ || (!scan.isSealed() && segmentIndex != manifest.segments.size() - 1)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_SEGMENT_ID_MISMATCH_ARG_9FE7E88A,
+ segmentPath));
+ }
+ expectedSegmentId = scan.getSegmentId() + 1;
+ if (scan.isSealed() && "SEALED".equals(segment.status)) {
+ final LogicalBackupSegmentReader.Footer footer = scan.getFooter();
+ if (segment.firstSequence != footer.getFirstSequence()
+ || segment.lastSequence != footer.getLastSequence()
+ || segment.recordCount != footer.getRecordCount()
+ || segment.sizeBytes != scan.getValidLength()
+ || !LogicalBackupFormat.toHex(footer.getDigest()).equals(segment.sha256)) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages
+ .EXCEPTION_LOGICAL_BACKUP_SEGMENT_METADATA_MISMATCH_ARG_376FD0B3,
+ segmentPath));
+ }
+ }
+
+ segment.firstSequence = -1;
+ segment.lastSequence = -1;
+ segment.recordCount = scan.getRecords().size();
+ segment.sizeBytes = scan.getValidLength();
+ segment.status = scan.isSealed() ? "SEALED" : "ACTIVE";
+ segment.sha256 =
+ scan.isSealed() ? LogicalBackupFormat.toHex(scan.getFooter().getDigest()) : null;
+
+ MessageDigest eventDigest = null;
+ UUID eventGroupId = null;
+ long eventFirstSequence = -1;
+ for (final LogicalBackupRecord record : scan.getRecords()) {
+ if (expectedSequence >= 0 && record.getSequence() != expectedSequence) {
+ throw new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_SEQUENCE_GAP_IN_ARG_D8698149,
+ segmentPath));
+ }
+ expectedSequence = record.getSequence() + 1;
+ segment.firstSequence =
+ segment.firstSequence < 0 ? record.getSequence() : segment.firstSequence;
+ segment.lastSequence = record.getSequence();
+ manifest.firstSequence =
+ manifest.firstSequence < 0 ? record.getSequence() : manifest.firstSequence;
+ manifest.lastSequence = record.getSequence();
+ manifest.operationCounts.merge(record.getRecordType().name(), 1L, Long::sum);
+ if (record.getRecordType() == LogicalBackupRecordType.SKIPPED_EVENT) {
+ manifest.skippedEventCount++;
+ } else if (record.getRecordType() == LogicalBackupRecordType.EVENT_BEGIN) {
+ eventDigest = LogicalBackupFormat.newSha256();
+ eventGroupId = record.getEventGroupId();
+ eventFirstSequence = record.getSequence();
+ } else if (record.getRecordType() == LogicalBackupRecordType.PIPE_REQUEST) {
+ updateEventDigest(
+ eventDigest,
+ record.getRequestVersion(),
+ record.getRequestType(),
+ record.getPayload());
+ } else if (record.getRecordType() == LogicalBackupRecordType.EVENT_COMMIT) {
+ manifest.lastEventGroupId = eventGroupId.toString();
+ manifest.lastEventDigest = LogicalBackupFormat.toHex(eventDigest.digest());
+ manifest.lastEventFirstSequence = eventFirstSequence;
+ eventDigest = null;
+ eventGroupId = null;
+ eventFirstSequence = -1;
+ }
+ }
+ }
+ nextSequence = Math.max(0, manifest.lastSequence + 1);
+ streamEnded =
+ manifest.lastSequence >= 0
+ && !manifest.segments.isEmpty()
+ && isLastRecordStreamEnd(reader.scan(currentSegmentPath(), false).getRecords());
+ }
+
+ private static boolean isLastRecordStreamEnd(final List records) {
+ return !records.isEmpty()
+ && records.get(records.size() - 1).getRecordType() == LogicalBackupRecordType.STREAM_END;
+ }
+
+ private Path currentSegmentPath() throws IOException {
+ if (manifest.segments.isEmpty()) {
+ throw new IOException(
+ LogicalBackupMessages.EXCEPTION_LOGICAL_BACKUP_HAS_NO_SEGMENTS_B48D5F15);
+ }
+ return resolveSegmentPath(manifest.segments.get(manifest.segments.size() - 1).file);
+ }
+
+ private Path resolveSegmentPath(final String file) throws IOException {
+ final Path relativePath;
+ try {
+ relativePath = file == null ? null : Path.of(file);
+ } catch (final RuntimeException e) {
+ throw invalidSegmentPath(file, e);
+ }
+ final Path normalizedDirectory = directory.toAbsolutePath().normalize();
+ if (relativePath == null || relativePath.isAbsolute() || relativePath.getNameCount() != 1) {
+ throw invalidSegmentPath(file, null);
+ }
+ final Path resolved = normalizedDirectory.resolve(relativePath).normalize();
+ if (!resolved.startsWith(normalizedDirectory)
+ || !normalizedDirectory.equals(resolved.getParent())) {
+ throw invalidSegmentPath(file, null);
+ }
+ return resolved;
+ }
+
+ private Path resolveExistingSegmentPath(final String file) throws IOException {
+ final Path resolved = resolveSegmentPath(file);
+ if (Files.isSymbolicLink(resolved)
+ || !Files.isRegularFile(resolved, LinkOption.NOFOLLOW_LINKS)) {
+ throw invalidSegmentPath(file, null);
+ }
+ return resolved;
+ }
+
+ private static IOException invalidSegmentPath(final String file, final Throwable cause) {
+ final IOException exception =
+ new IOException(
+ String.format(
+ LogicalBackupMessages.EXCEPTION_INVALID_LOGICAL_BACKUP_SEGMENT_PATH_ARG_6485A845,
+ file));
+ if (cause != null) {
+ exception.initCause(cause);
+ }
+ return exception;
+ }
+
+ private void updateActiveSegmentManifest() throws IOException {
+ if (channel == null || manifest.segments.isEmpty()) {
+ return;
+ }
+ final LogicalBackupManifest.Segment segment =
+ manifest.segments.get(manifest.segments.size() - 1);
+ segment.firstSequence = segmentFirstSequence;
+ segment.lastSequence = segmentLastSequence;
+ segment.recordCount = segmentRecordCount;
+ segment.sizeBytes = channel.position();
+ segment.status = "ACTIVE";
+ }
+}
diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReaderTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReaderTest.java
new file mode 100644
index 000000000000..05f6298e95fa
--- /dev/null
+++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReaderTest.java
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader.BackupStream;
+import org.apache.iotdb.service.rpc.thrift.TPipeTransferReq;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+public class LogicalBackupArchiveReaderTest {
+
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+ private static final int MAX_RECORD_BYTES = 1024;
+
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testReadCompleteArchiveAndOrderSchemaFirst() throws Exception {
+ final Path root = temporaryFolder.newFolder().toPath();
+ writeStream(root.resolve("data"), "data-stream", "data", 4096);
+ writeStream(root.resolve("schema"), "schema-stream", "schema", 4096);
+
+ final List streams = new LogicalBackupArchiveReader().read(root, false);
+
+ Assert.assertEquals(2, streams.size());
+ Assert.assertEquals("schema-stream", streams.get(0).getManifest().streamId);
+ Assert.assertEquals("data-stream", streams.get(1).getManifest().streamId);
+ Assert.assertEquals(1, streams.get(0).getEventGroups().size());
+ Assert.assertEquals(4, streams.get(0).getRecords().size());
+ Assert.assertEquals(
+ request(1),
+ streams.get(0).getEventGroups().get(0).getRequests().get(0).toTPipeTransferReq());
+ }
+
+ @Test
+ public void testManifestCounterMismatchIsRejected() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ writeStream(directory, "stream", "data", 4096);
+ final Path manifestPath = directory.resolve(LogicalBackupFormat.MANIFEST_FILE_NAME);
+ final LogicalBackupManifest manifest =
+ GSON.fromJson(
+ Files.readString(manifestPath, StandardCharsets.UTF_8), LogicalBackupManifest.class);
+ manifest.operationCounts.clear();
+ Files.writeString(manifestPath, GSON.toJson(manifest), StandardCharsets.UTF_8);
+
+ assertReadFails(directory, false);
+ }
+
+ @Test
+ public void testUnlistedSegmentIsRejected() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ writeStream(directory, "stream", "data", 4096);
+ Files.copy(onlySegment(directory), directory.resolve("unlisted.pwal"));
+
+ assertReadFails(directory, false);
+ }
+
+ @Test
+ public void testIncompleteLastSegmentRequiresExplicitOptIn() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ writeStream(directory, "stream", "data", 4096);
+ final Path segment = onlySegment(directory);
+ try (final RandomAccessFile file = new RandomAccessFile(segment.toFile(), "rw")) {
+ file.setLength(file.length() - LogicalBackupFormat.SEGMENT_FOOTER_SIZE);
+ }
+
+ assertReadFails(directory, false);
+ final List streams = new LogicalBackupArchiveReader().read(directory, true);
+ Assert.assertEquals(1, streams.size());
+ Assert.assertEquals(1, streams.get(0).getEventGroups().size());
+ }
+
+ @Test
+ public void testUnsealedNonLastSegmentIsRejected() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ writeStream(directory, "stream", "data", 500);
+ final List segments = segments(directory);
+ Assert.assertTrue(segments.size() > 1);
+ try (final RandomAccessFile file = new RandomAccessFile(segments.get(0).toFile(), "rw")) {
+ file.setLength(file.length() - LogicalBackupFormat.SEGMENT_FOOTER_SIZE);
+ }
+
+ assertReadFails(directory, true);
+ }
+
+ @Test
+ public void testParentSegmentPathIsRejected() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ writeStream(directory, "stream", "data", 4096);
+ final Path manifestPath = directory.resolve(LogicalBackupFormat.MANIFEST_FILE_NAME);
+ final LogicalBackupManifest manifest =
+ GSON.fromJson(
+ Files.readString(manifestPath, StandardCharsets.UTF_8), LogicalBackupManifest.class);
+ manifest.segments.get(0).file = "../outside.pwal";
+ Files.writeString(manifestPath, GSON.toJson(manifest), StandardCharsets.UTF_8);
+
+ assertReadFails(directory, false);
+ }
+
+ @Test
+ public void testSkippedEventRequiresExplicitIncompleteOptIn() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ final LogicalBackupManifest manifest = manifest("stream", "data");
+ try (final LogicalBackupWriter writer = writer(directory, manifest, 4096)) {
+ writer.recordSkippedEvent(1, "unsupported-event");
+ }
+
+ assertReadFails(directory, false);
+ Assert.assertEquals(1, new LogicalBackupArchiveReader().read(directory, true).size());
+
+ final Path manifestPath = directory.resolve(LogicalBackupFormat.MANIFEST_FILE_NAME);
+ final LogicalBackupManifest persistedManifest =
+ GSON.fromJson(
+ Files.readString(manifestPath, StandardCharsets.UTF_8), LogicalBackupManifest.class);
+ persistedManifest.skippedEventCount = 0;
+ Files.writeString(manifestPath, GSON.toJson(persistedManifest), StandardCharsets.UTF_8);
+ assertReadFails(directory, false);
+ }
+
+ @Test
+ public void testMixedBackupSessionsAreRejected() throws Exception {
+ final Path root = temporaryFolder.newFolder().toPath();
+ writeStream(root.resolve("data"), "data-stream", "data", 4096);
+ writeStream(root.resolve("schema"), "schema-stream", "schema", 4096);
+ final Path manifestPath =
+ root.resolve("schema").resolve(LogicalBackupFormat.MANIFEST_FILE_NAME);
+ final LogicalBackupManifest manifest =
+ GSON.fromJson(
+ Files.readString(manifestPath, StandardCharsets.UTF_8), LogicalBackupManifest.class);
+ manifest.backupId = "another-backup";
+ Files.writeString(manifestPath, GSON.toJson(manifest), StandardCharsets.UTF_8);
+
+ assertReadFails(root, false);
+ }
+
+ @Test
+ public void testNonReplayableRequestTypeIsRejected() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, manifest("stream", "data"), 4096)) {
+ writer.writeEvent(
+ UUID.randomUUID(),
+ 1,
+ Collections.singletonList(
+ new TPipeTransferReq()
+ .setVersion((byte) 1)
+ .setType((short) 200)
+ .setBody(ByteBuffer.wrap(new byte[] {1}))),
+ "metadata");
+ }
+
+ assertReadFails(directory, false);
+ }
+
+ @Test
+ public void testDuplicateEventGroupIdIsRejected() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ final UUID eventGroupId = UUID.randomUUID();
+ try (final LogicalBackupWriter writer = writer(directory, manifest("stream", "data"), 4096)) {
+ writer.writeEvent(eventGroupId, 1, Collections.singletonList(request(1)), "metadata");
+ writer.writeEvent(UUID.randomUUID(), 2, Collections.singletonList(request(2)), "metadata");
+ writer.writeEvent(eventGroupId, 3, Collections.singletonList(request(1)), "metadata");
+ }
+
+ assertReadFails(directory, false);
+ }
+
+ private static void writeStream(
+ final Path directory, final String streamId, final String streamType, final long segmentSize)
+ throws Exception {
+ try (final LogicalBackupWriter writer =
+ writer(directory, manifest(streamId, streamType), segmentSize)) {
+ writer.writeEvent(UUID.randomUUID(), 1, Collections.singletonList(request(1)), "metadata");
+ if (segmentSize < 1000) {
+ writer.writeEvent(
+ UUID.randomUUID(), 2, Collections.singletonList(request(new byte[20])), "metadata");
+ }
+ }
+ }
+
+ private static LogicalBackupManifest manifest(final String streamId, final String streamType) {
+ final LogicalBackupManifest manifest = new LogicalBackupManifest();
+ manifest.backupId = "backup";
+ manifest.pipeName = "pipe";
+ manifest.pipeCreationTime = 1;
+ manifest.streamId = streamId;
+ manifest.streamType = streamType;
+ manifest.timestampPrecision = "ms";
+ return manifest;
+ }
+
+ private static LogicalBackupWriter writer(
+ final Path directory, final LogicalBackupManifest manifest, final long segmentSize)
+ throws IOException {
+ return new LogicalBackupWriter(
+ directory,
+ manifest,
+ segmentSize,
+ MAX_RECORD_BYTES,
+ LogicalBackupWriter.FsyncPolicy.ALWAYS,
+ 1,
+ 1,
+ false);
+ }
+
+ private static TPipeTransferReq request(final int value) {
+ return request(new byte[] {(byte) value});
+ }
+
+ private static TPipeTransferReq request(final byte[] body) {
+ return new TPipeTransferReq()
+ .setVersion((byte) 1)
+ .setType((short) 10)
+ .setBody(ByteBuffer.wrap(body));
+ }
+
+ private static Path onlySegment(final Path directory) throws IOException {
+ final List segments = segments(directory);
+ Assert.assertEquals(1, segments.size());
+ return segments.get(0);
+ }
+
+ private static List segments(final Path directory) throws IOException {
+ try (final java.util.stream.Stream files = Files.list(directory)) {
+ return files
+ .filter(path -> path.getFileName().toString().endsWith(".pwal"))
+ .sorted()
+ .collect(java.util.stream.Collectors.toList());
+ }
+ }
+
+ private static void assertReadFails(final Path source, final boolean allowIncomplete)
+ throws IOException {
+ try {
+ new LogicalBackupArchiveReader().read(source, allowIncomplete);
+ Assert.fail();
+ } catch (final IOException expected) {
+ // Expected.
+ }
+ }
+}
diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriterTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriterTest.java
new file mode 100644
index 000000000000..b6806f3407be
--- /dev/null
+++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriterTest.java
@@ -0,0 +1,588 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.commons.pipe.sink.logicalbackup;
+
+import org.apache.iotdb.service.rpc.thrift.TPipeTransferReq;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.AbstractList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+public class LogicalBackupWriterTest {
+
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+ private static final int MAX_RECORD_BYTES = 1024;
+
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testRoundTripAndDuplicateEvent() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ final UUID eventId = UUID.randomUUID();
+ final TPipeTransferReq request = request((byte) 1, (short) 2, new byte[] {3, 4, 5});
+
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ Assert.assertEquals(
+ 0, writer.writeEvent(eventId, 123, Collections.singletonList(request), "m"));
+ Assert.assertEquals(
+ 0, writer.writeEvent(eventId, 123, Collections.singletonList(request), "m"));
+ }
+
+ final LogicalBackupSegmentReader.ScanResult result = scanOnlySegment(directory);
+ Assert.assertTrue(result.isSealed());
+ Assert.assertEquals(4, result.getRecords().size());
+ Assert.assertEquals(
+ LogicalBackupRecordType.EVENT_BEGIN, result.getRecords().get(0).getRecordType());
+ Assert.assertEquals(
+ LogicalBackupRecordType.PIPE_REQUEST, result.getRecords().get(1).getRecordType());
+ Assert.assertEquals(request, result.getRecords().get(1).toTPipeTransferReq());
+ Assert.assertEquals(
+ LogicalBackupRecordType.EVENT_COMMIT, result.getRecords().get(2).getRecordType());
+ Assert.assertEquals(
+ LogicalBackupRecordType.STREAM_END, result.getRecords().get(3).getRecordType());
+ Assert.assertEquals(0, result.getFooter().getFirstSequence());
+ Assert.assertEquals(3, result.getFooter().getLastSequence());
+ }
+
+ @Test
+ public void testDuplicateEventWithDifferentDigestFails() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ final UUID eventId = UUID.randomUUID();
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ writer.writeEvent(eventId, 1, Collections.singletonList(request(1)), "");
+ try {
+ writer.writeEvent(eventId, 1, Collections.singletonList(request(2)), "");
+ Assert.fail();
+ } catch (final IOException expected) {
+ Assert.assertTrue(expected.getMessage().contains(eventId.toString()));
+ }
+ }
+ }
+
+ @Test
+ public void testOnlineRollbackAfterPartialEvent() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ final List requests =
+ new AbstractList() {
+ private int accesses;
+
+ @Override
+ public TPipeTransferReq get(final int index) {
+ if (++accesses == 6) {
+ throw new IllegalStateException();
+ }
+ return request(index + 1);
+ }
+
+ @Override
+ public int size() {
+ return 2;
+ }
+ };
+ try {
+ writer.writeEvent(UUID.randomUUID(), 1, requests, "");
+ Assert.fail();
+ } catch (final IllegalStateException expected) {
+ // Expected.
+ }
+ Assert.assertEquals(
+ 0,
+ writer.writeEvent(
+ UUID.randomUUID(), 2, Collections.singletonList(request(3)), "after-rollback"));
+ }
+
+ final List records = scanOnlySegment(directory).getRecords();
+ Assert.assertEquals(4, records.size());
+ Assert.assertEquals(0, records.get(0).getSequence());
+ Assert.assertEquals("after-rollback", records.get(0).getMetadata());
+ }
+
+ @Test
+ public void testRecoverIncompleteEventAndRebuildCounts() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ writer.writeEvent(UUID.randomUUID(), 1, Collections.singletonList(request(1)), "");
+ }
+ final Path segment = onlySegment(directory);
+ final long beginAndRequestLength =
+ LogicalBackupFormat.SEGMENT_HEADER_SIZE + frameSize(0, 0) + frameSize(0, 1);
+ try (final RandomAccessFile file = new RandomAccessFile(segment.toFile(), "rw")) {
+ file.setLength(beginAndRequestLength);
+ }
+
+ try (final LogicalBackupWriter writer = writer(directory, 4096, true)) {
+ Assert.assertEquals(
+ 0, writer.writeEvent(UUID.randomUUID(), 2, Collections.singletonList(request(2)), ""));
+ Assert.assertTrue(writer.getManifest().recovered);
+ Assert.assertEquals(Long.valueOf(1), writer.getManifest().operationCounts.get("EVENT_BEGIN"));
+ Assert.assertEquals(
+ Long.valueOf(1), writer.getManifest().operationCounts.get("PIPE_REQUEST"));
+ Assert.assertEquals(
+ Long.valueOf(1), writer.getManifest().operationCounts.get("EVENT_COMMIT"));
+ }
+ }
+
+ @Test
+ public void testRecoverIncompleteFrame() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ writer.writeEvent(UUID.randomUUID(), 1, Collections.singletonList(request(1)), "");
+ }
+ final Path segment = onlySegment(directory);
+ try (final RandomAccessFile file = new RandomAccessFile(segment.toFile(), "rw")) {
+ file.setLength(file.length() - LogicalBackupFormat.SEGMENT_FOOTER_SIZE - 2);
+ }
+
+ try (final LogicalBackupWriter writer = writer(directory, 4096, true)) {
+ Assert.assertTrue(writer.getManifest().recovered);
+ Assert.assertEquals(2, writer.getManifest().lastSequence);
+ }
+ }
+
+ @Test
+ public void testRecoverPartialFooter() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ writer.writeEvent(UUID.randomUUID(), 1, Collections.singletonList(request(1)), "");
+ }
+ final Path segment = onlySegment(directory);
+ try (final RandomAccessFile file = new RandomAccessFile(segment.toFile(), "rw")) {
+ file.setLength(file.length() - LogicalBackupFormat.SEGMENT_FOOTER_SIZE + 8);
+ }
+
+ try (final LogicalBackupWriter writer = writer(directory, 4096, true)) {
+ Assert.assertTrue(writer.getManifest().recovered);
+ Assert.assertEquals(
+ 4, writer.writeEvent(UUID.randomUUID(), 2, Collections.singletonList(request(2)), ""));
+ }
+ Assert.assertTrue(
+ new LogicalBackupSegmentReader(MAX_RECORD_BYTES)
+ .scan(segments(directory).get(segments(directory).size() - 1), false)
+ .isSealed());
+ }
+
+ @Test
+ public void testRecoverCorruptFooterWhenManifestStillMarksSegmentActive() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ writer.writeEvent(UUID.randomUUID(), 1, Collections.singletonList(request(1)), "");
+ }
+ final Path segment = onlySegment(directory);
+ try (final RandomAccessFile file = new RandomAccessFile(segment.toFile(), "rw")) {
+ final long lastByteOffset = file.length() - 1;
+ file.seek(lastByteOffset);
+ final int lastByte = file.readUnsignedByte();
+ file.seek(lastByteOffset);
+ file.writeByte(lastByte ^ 1);
+ }
+ final Path manifestPath = directory.resolve(LogicalBackupFormat.MANIFEST_FILE_NAME);
+ final LogicalBackupManifest manifest =
+ GSON.fromJson(Files.readString(manifestPath), LogicalBackupManifest.class);
+ manifest.status = "WRITING";
+ manifest.closedAt = null;
+ manifest.segments.get(0).status = "ACTIVE";
+ manifest.segments.get(0).sizeBytes -= LogicalBackupFormat.SEGMENT_FOOTER_SIZE;
+ manifest.segments.get(0).sha256 = null;
+ Files.writeString(manifestPath, GSON.toJson(manifest));
+
+ try (final LogicalBackupWriter writer = writer(directory, 4096, true)) {
+ Assert.assertTrue(writer.getManifest().recovered);
+ }
+ Assert.assertTrue(scanOnlySegment(directory).isSealed());
+ }
+
+ @Test
+ public void testChecksumDamageFails() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ writer.writeEvent(UUID.randomUUID(), 1, Collections.singletonList(request(1)), "metadata");
+ }
+ final Path segment = onlySegment(directory);
+ try (final RandomAccessFile file = new RandomAccessFile(segment.toFile(), "rw")) {
+ final long payloadOffset =
+ LogicalBackupFormat.SEGMENT_HEADER_SIZE
+ + frameSize("metadata".length(), 0)
+ + LogicalBackupFormat.RECORD_HEADER_SIZE
+ + "metadata".length();
+ file.seek(payloadOffset);
+ file.writeByte(file.readByte() ^ 1);
+ }
+ try {
+ new LogicalBackupSegmentReader(MAX_RECORD_BYTES).scan(segment, false);
+ Assert.fail();
+ } catch (final IOException expected) {
+ Assert.assertTrue(expected.getMessage().contains("CRC"));
+ }
+ }
+
+ @Test
+ public void testFooterSequenceDamageFails() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ writer.writeEvent(UUID.randomUUID(), 1, Collections.singletonList(request(1)), "");
+ }
+ final Path segment = onlySegment(directory);
+ try (final RandomAccessFile file = new RandomAccessFile(segment.toFile(), "rw")) {
+ final long footerOffset = file.length() - LogicalBackupFormat.SEGMENT_FOOTER_SIZE;
+ final byte[] footer = new byte[LogicalBackupFormat.SEGMENT_FOOTER_SIZE];
+ file.seek(footerOffset);
+ file.readFully(footer);
+ ByteBuffer.wrap(footer).order(ByteOrder.BIG_ENDIAN).putLong(Integer.BYTES + Long.BYTES, 99);
+ ByteBuffer.wrap(footer)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putInt(
+ LogicalBackupFormat.SEGMENT_FOOTER_SIZE - Integer.BYTES,
+ LogicalBackupFormat.crc32c(
+ footer, 0, LogicalBackupFormat.SEGMENT_FOOTER_SIZE - Integer.BYTES));
+ file.seek(footerOffset);
+ file.write(footer);
+ }
+ try {
+ new LogicalBackupSegmentReader(MAX_RECORD_BYTES).scan(segment, false);
+ Assert.fail();
+ } catch (final IOException expected) {
+ Assert.assertTrue(expected.getMessage().contains("metadata"));
+ }
+ }
+
+ @Test
+ public void testSegmentRollover() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 500, false)) {
+ writer.writeEvent(
+ UUID.randomUUID(), 1, Collections.singletonList(request(new byte[20])), "m");
+ writer.writeEvent(
+ UUID.randomUUID(), 2, Collections.singletonList(request(new byte[20])), "m");
+ }
+ final List segments = segments(directory);
+ Assert.assertEquals(2, segments.size());
+ Assert.assertTrue(
+ new LogicalBackupSegmentReader(MAX_RECORD_BYTES).scan(segments.get(0), false).isSealed());
+ Assert.assertTrue(
+ new LogicalBackupSegmentReader(MAX_RECORD_BYTES).scan(segments.get(1), false).isSealed());
+ }
+
+ @Test
+ public void testSealedSegmentAdvancesDurableSequenceWithFsyncNone() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ final LogicalBackupManifest manifest = manifest();
+ try (final LogicalBackupWriter writer =
+ new LogicalBackupWriter(
+ directory,
+ manifest,
+ 500,
+ MAX_RECORD_BYTES,
+ LogicalBackupWriter.FsyncPolicy.NONE,
+ 1,
+ 1,
+ false)) {
+ writer.writeEvent(
+ UUID.randomUUID(), 1, Collections.singletonList(request(new byte[20])), "m");
+ writer.writeEvent(
+ UUID.randomUUID(), 2, Collections.singletonList(request(new byte[20])), "m");
+ Assert.assertEquals(2, writer.getManifest().lastDurableSequence);
+ }
+ }
+
+ @Test
+ public void testControlRecordRollover() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer = writer(directory, 258, false)) {
+ writer.writeControl(LogicalBackupRecordType.HEARTBEAT, 1, "first");
+ writer.writeControl(LogicalBackupRecordType.HEARTBEAT, 2, "second");
+ Assert.assertEquals(2, writer.getManifest().segments.size());
+ Assert.assertEquals(1, writer.getManifest().lastSequence);
+ }
+ }
+
+ @Test
+ public void testDirectoryLock() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter ignored = writer(directory, 4096, false)) {
+ try {
+ writer(directory, 4096, true);
+ Assert.fail();
+ } catch (final IOException expected) {
+ Assert.assertTrue(expected.getMessage().contains("locked"));
+ }
+ }
+ }
+
+ @Test
+ public void testFailIfDirectoryExists() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath();
+ try {
+ writer(directory, 4096, false);
+ Assert.fail();
+ } catch (final IOException expected) {
+ Assert.assertTrue(expected.getMessage().contains(directory.toString()));
+ }
+ }
+
+ @Test
+ public void testAppendRejectsConfigurationMismatch() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter ignored = writer(directory, 4096, false)) {
+ // Create the backup.
+ }
+ try {
+ writer(directory, 8192, true);
+ Assert.fail();
+ } catch (final IOException expected) {
+ Assert.assertTrue(expected.getMessage().contains("segmentSizeBytes"));
+ }
+ }
+
+ @Test
+ public void testAppendRejectsParentSegmentPath() throws Exception {
+ final Path root = temporaryFolder.newFolder().toPath();
+ final Path directory = root.resolve("backup");
+ try (final LogicalBackupWriter ignored = writer(directory, 4096, false)) {
+ // Create the backup.
+ }
+ final Path manifestPath = directory.resolve(LogicalBackupFormat.MANIFEST_FILE_NAME);
+ final LogicalBackupManifest manifest =
+ GSON.fromJson(Files.readString(manifestPath), LogicalBackupManifest.class);
+ final String originalSegment = manifest.segments.get(0).file;
+ Files.copy(directory.resolve(originalSegment), root.resolve("outside.pwal"));
+ manifest.segments.get(0).file = "../outside.pwal";
+ Files.writeString(manifestPath, GSON.toJson(manifest));
+
+ try {
+ writer(directory, 4096, true);
+ Assert.fail();
+ } catch (final IOException expected) {
+ Assert.assertTrue(expected.getMessage().contains("segment path"));
+ }
+
+ manifest.segments.get(0).file = originalSegment;
+ Files.writeString(manifestPath, GSON.toJson(manifest));
+ try (final LogicalBackupWriter ignored = writer(directory, 4096, true)) {
+ // A failed append must release the directory lock.
+ }
+ }
+
+ @Test
+ public void testAppendAdoptsOrphanHeaderOnlySegmentAndClearsClosedState() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter ignored = writer(directory, 4096, false)) {
+ // Create and seal the initial segment.
+ }
+
+ final Path orphan = directory.resolve(segmentFileName(1));
+ Files.write(orphan, segmentHeader(1, 123));
+ try (final LogicalBackupWriter writer = writer(directory, 4096, true)) {
+ Assert.assertEquals("WRITING", writer.getManifest().status);
+ Assert.assertNull(writer.getManifest().closedAt);
+ Assert.assertEquals(2, writer.getManifest().segments.size());
+ Assert.assertEquals(
+ 1, writer.writeEvent(UUID.randomUUID(), 2, Collections.singletonList(request(2)), ""));
+ }
+
+ final LogicalBackupSegmentReader.ScanResult scan =
+ new LogicalBackupSegmentReader(MAX_RECORD_BYTES).scan(orphan, false);
+ Assert.assertTrue(scan.isSealed());
+ Assert.assertEquals(123, scan.getCreatedAt());
+ }
+
+ @Test
+ public void testAppendRejectsNonEmptyOrphanSegment() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter ignored = writer(directory, 4096, false)) {
+ // Create and seal the initial segment.
+ }
+
+ final Path orphan = directory.resolve(segmentFileName(1));
+ final byte[] invalidOrphan =
+ Arrays.copyOf(segmentHeader(1, 123), LogicalBackupFormat.SEGMENT_HEADER_SIZE + 1);
+ Files.write(orphan, invalidOrphan);
+ try {
+ writer(directory, 4096, true);
+ Assert.fail();
+ } catch (final IOException expected) {
+ // The extra byte is an incomplete record tail and must never be adopted.
+ }
+ }
+
+ @Test
+ public void testAppendRejectsUnexpectedUnlistedSegment() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter ignored = writer(directory, 4096, false)) {
+ // Create and seal the initial segment.
+ }
+
+ final Path unexpected = directory.resolve(segmentFileName(99));
+ Files.write(unexpected, segmentHeader(99, 123));
+ try {
+ writer(directory, 4096, true);
+ Assert.fail();
+ } catch (final IOException expected) {
+ Assert.assertTrue(expected.getMessage().contains("Unlisted"));
+ }
+ }
+
+ @Test
+ public void testPeriodicFsyncAdvancesOnHeartbeat() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ final LogicalBackupManifest manifest = new LogicalBackupManifest();
+ manifest.backupId = "backup";
+ manifest.pipeName = "pipe";
+ manifest.pipeCreationTime = 1;
+ manifest.streamId = "stream";
+ manifest.streamType = "data";
+ try (final LogicalBackupWriter writer =
+ new LogicalBackupWriter(
+ directory,
+ manifest,
+ 4096,
+ MAX_RECORD_BYTES,
+ LogicalBackupWriter.FsyncPolicy.PERIODIC,
+ 1000,
+ 100,
+ false)) {
+ writer.writeEvent(UUID.randomUUID(), 1, Collections.singletonList(request(1)), "");
+ Assert.assertEquals(-1, writer.getManifest().lastDurableSequence);
+ Thread.sleep(150);
+ writer.heartbeat();
+ Assert.assertEquals(
+ writer.getManifest().lastSequence, writer.getManifest().lastDurableSequence);
+ }
+ }
+
+ @Test
+ public void testMaxRecordBytesHasHardLimit() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ final LogicalBackupManifest manifest = new LogicalBackupManifest();
+ try {
+ new LogicalBackupWriter(
+ directory,
+ manifest,
+ 4096,
+ LogicalBackupFormat.MAX_RECORD_BYTES + 1,
+ LogicalBackupWriter.FsyncPolicy.ALWAYS,
+ 1,
+ 1,
+ false);
+ Assert.fail();
+ } catch (final IOException expected) {
+ // Expected.
+ }
+ }
+
+ private static LogicalBackupWriter writer(
+ final Path directory, final long segmentSize, final boolean append) throws IOException {
+ return new LogicalBackupWriter(
+ directory,
+ manifest(),
+ segmentSize,
+ MAX_RECORD_BYTES,
+ LogicalBackupWriter.FsyncPolicy.ALWAYS,
+ 1,
+ 1,
+ append);
+ }
+
+ private static LogicalBackupManifest manifest() {
+ final LogicalBackupManifest manifest = new LogicalBackupManifest();
+ manifest.backupId = "backup";
+ manifest.pipeName = "pipe";
+ manifest.pipeCreationTime = 1;
+ manifest.streamId = "stream";
+ manifest.streamType = "data";
+ return manifest;
+ }
+
+ private static TPipeTransferReq request(final int value) {
+ return request(new byte[] {(byte) value});
+ }
+
+ private static TPipeTransferReq request(final byte[] body) {
+ return request((byte) 1, (short) 2, body);
+ }
+
+ private static TPipeTransferReq request(final byte version, final short type, final byte[] body) {
+ return new TPipeTransferReq().setVersion(version).setType(type).setBody(ByteBuffer.wrap(body));
+ }
+
+ private static LogicalBackupSegmentReader.ScanResult scanOnlySegment(final Path directory)
+ throws IOException {
+ return new LogicalBackupSegmentReader(MAX_RECORD_BYTES).scan(onlySegment(directory), false);
+ }
+
+ private static Path onlySegment(final Path directory) throws IOException {
+ final List segments = segments(directory);
+ Assert.assertEquals(1, segments.size());
+ return segments.get(0);
+ }
+
+ private static List segments(final Path directory) throws IOException {
+ try (final java.util.stream.Stream files = Files.list(directory)) {
+ final Path[] segments =
+ files
+ .filter(path -> path.getFileName().toString().endsWith(".pwal"))
+ .sorted()
+ .toArray(Path[]::new);
+ return Arrays.asList(segments);
+ }
+ }
+
+ private static long frameSize(final int metadataBytes, final int payloadBytes) {
+ return LogicalBackupFormat.RECORD_HEADER_SIZE + metadataBytes + payloadBytes + Integer.BYTES;
+ }
+
+ private static String segmentFileName(final long segmentId) {
+ return String.format(java.util.Locale.ROOT, "segment-%020d.pwal", segmentId);
+ }
+
+ private static byte[] segmentHeader(final long segmentId, final long createdAt) {
+ final ByteBuffer header =
+ ByteBuffer.allocate(LogicalBackupFormat.SEGMENT_HEADER_SIZE).order(ByteOrder.BIG_ENDIAN);
+ header.putLong(LogicalBackupFormat.SEGMENT_MAGIC);
+ header.putShort(LogicalBackupFormat.MAJOR_VERSION);
+ header.putShort(LogicalBackupFormat.MINOR_VERSION);
+ header.putLong(segmentId);
+ header.putLong(createdAt);
+ header.putInt(0);
+ final byte[] bytes = header.array();
+ ByteBuffer.wrap(bytes)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putInt(
+ LogicalBackupFormat.SEGMENT_HEADER_SIZE - Integer.BYTES,
+ LogicalBackupFormat.crc32c(
+ bytes, 0, LogicalBackupFormat.SEGMENT_HEADER_SIZE - Integer.BYTES));
+ return bytes;
+ }
+}
diff --git a/scripts/tools/export-pipe-logical-backup.sh b/scripts/tools/export-pipe-logical-backup.sh
new file mode 100644
index 000000000000..8a0a4f8a1834
--- /dev/null
+++ b/scripts/tools/export-pipe-logical-backup.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+exec "$(dirname "$0")/pipe-logical-backup.sh" export "$@"
diff --git a/scripts/tools/import-pipe-logical-backup.sh b/scripts/tools/import-pipe-logical-backup.sh
new file mode 100644
index 000000000000..6e4e069210de
--- /dev/null
+++ b/scripts/tools/import-pipe-logical-backup.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+exec "$(dirname "$0")/pipe-logical-backup.sh" import "$@"
diff --git a/scripts/tools/pipe-logical-backup.sh b/scripts/tools/pipe-logical-backup.sh
new file mode 100644
index 000000000000..a0f6a3529f74
--- /dev/null
+++ b/scripts/tools/pipe-logical-backup.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+if [ -n "${IOTDB_INCLUDE}" ] && [ -r "${IOTDB_INCLUDE}" ]; then
+ . "${IOTDB_INCLUDE}"
+fi
+
+if [ -z "${IOTDB_HOME}" ]; then
+ IOTDB_HOME="$(cd "$(dirname "$0")/.."; pwd)"
+ export IOTDB_HOME
+fi
+
+if [ -n "${JAVA_HOME}" ] && [ -x "${JAVA_HOME}/bin/java" ]; then
+ JAVA="${JAVA_HOME}/bin/java"
+else
+ JAVA=java
+fi
+
+CLASSPATH=""
+for jar in "${IOTDB_HOME}"/lib/*.jar; do
+ CLASSPATH="${CLASSPATH}:${jar}"
+done
+
+exec "${JAVA}" -Dsun.jnu.encoding=UTF-8 -Dfile.encoding=UTF-8 \
+ -DIOTDB_HOME="${IOTDB_HOME}" -cp "${CLASSPATH}" \
+ org.apache.iotdb.tool.pipe.PipeLogicalBackupTool "$@"
diff --git a/scripts/tools/windows/export-pipe-logical-backup.bat b/scripts/tools/windows/export-pipe-logical-backup.bat
new file mode 100644
index 000000000000..42e1df8afb27
--- /dev/null
+++ b/scripts/tools/windows/export-pipe-logical-backup.bat
@@ -0,0 +1,21 @@
+@REM
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM
+
+@call "%~dp0pipe-logical-backup.bat" export %*
+@exit /B %ERRORLEVEL%
diff --git a/scripts/tools/windows/import-pipe-logical-backup.bat b/scripts/tools/windows/import-pipe-logical-backup.bat
new file mode 100644
index 000000000000..0fba332d5a2e
--- /dev/null
+++ b/scripts/tools/windows/import-pipe-logical-backup.bat
@@ -0,0 +1,21 @@
+@REM
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM
+
+@call "%~dp0pipe-logical-backup.bat" import %*
+@exit /B %ERRORLEVEL%
diff --git a/scripts/tools/windows/pipe-logical-backup.bat b/scripts/tools/windows/pipe-logical-backup.bat
new file mode 100644
index 000000000000..14e3180e4699
--- /dev/null
+++ b/scripts/tools/windows/pipe-logical-backup.bat
@@ -0,0 +1,40 @@
+@REM
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM
+
+@echo off
+if "%OS%" == "Windows_NT" setlocal
+
+pushd %~dp0..\..
+if NOT DEFINED IOTDB_HOME set IOTDB_HOME=%CD%
+popd
+
+if NOT DEFINED JAVA_HOME goto :no_java
+set CLASSPATH=%CLASSPATH%;"%IOTDB_HOME%\lib\*"
+"%JAVA_HOME%\bin\java" -Dsun.jnu.encoding=UTF-8 -Dfile.encoding=UTF-8 ^
+ -DIOTDB_HOME="%IOTDB_HOME%" -cp %CLASSPATH% ^
+ org.apache.iotdb.tool.pipe.PipeLogicalBackupTool %*
+set ret_code=%ERRORLEVEL%
+goto :finally
+
+:no_java
+echo JAVA_HOME environment variable must be set!
+set ret_code=1
+
+:finally
+endlocal & exit /B %ret_code%
From ce4b30d0ea26d433ca780e03fb691100ab15c268 Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Fri, 11 Sep 2026 12:03:37 +0800
Subject: [PATCH 2/8] Fix logical backup tablet serialization error handling
---
.../logicalbackup/LogicalBackupSink.java | 19 ++++++++++++-------
1 file changed, 12 insertions(+), 7 deletions(-)
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
index 5050107c05f9..594d49922f7e 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
@@ -51,6 +51,7 @@
import org.apache.iotdb.pipe.api.event.Event;
import org.apache.iotdb.pipe.api.event.dml.insertion.TabletInsertionEvent;
import org.apache.iotdb.pipe.api.event.dml.insertion.TsFileInsertionEvent;
+import org.apache.iotdb.pipe.api.exception.PipeException;
import org.apache.iotdb.pipe.api.exception.PipeParameterNotValidException;
import org.apache.iotdb.service.rpc.thrift.TPipeTransferReq;
@@ -389,13 +390,17 @@ public void transfer(final TsFileInsertionEvent event) throws Exception {
try {
tsFile.consumeTabletInsertionEventsWithRetry(
tablet -> {
- requests.add(
- PipeTransferTabletRawReqV2.toTPipeTransferReq(
- tablet.convertToTablet(),
- tablet.isAligned(),
- tablet.isTableModelEvent()
- ? tablet.getTableModelDatabaseName()
- : tablet.getTreeModelDatabaseName()));
+ try {
+ requests.add(
+ PipeTransferTabletRawReqV2.toTPipeTransferReq(
+ tablet.convertToTablet(),
+ tablet.isAligned(),
+ tablet.isTableModelEvent()
+ ? tablet.getTableModelDatabaseName()
+ : tablet.getTreeModelDatabaseName()));
+ } catch (final IOException e) {
+ throw new PipeException(e.getMessage(), e);
+ }
},
getClass().getName());
if (!requests.isEmpty()) {
From 218dcb43001c4f99f5905d0cba032ee7e62478c1 Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Wed, 16 Sep 2026 12:06:00 +0800
Subject: [PATCH 3/8] Harden logical backup failover and import recovery
---
.../apache/iotdb/cli/i18n/CliMessages.java | 5 +-
.../apache/iotdb/cli/i18n/CliMessages.java | 5 +-
.../tool/pipe/PipeLogicalBackupTool.java | 191 +++++++++++++-----
.../tool/pipe/PipeLogicalBackupToolTest.java | 111 +++++++++-
.../iotdb/db/i18n/DataNodePipeMessages.java | 3 +
.../iotdb/db/i18n/DataNodePipeMessages.java | 3 +
.../logicalbackup/LogicalBackupSink.java | 25 +++
.../logicalbackup/LogicalBackupSinkTest.java | 79 ++++++++
.../LogicalBackupWriterTest.java | 30 +++
9 files changed, 390 insertions(+), 62 deletions(-)
create mode 100644 iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSinkTest.java
diff --git a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
index 77888e65768a..8aa2feccc907 100644
--- a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -130,9 +130,8 @@ private CliMessages() {}
public static final String
LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_4677380E =
"Use --input to specify the input. Export also requires --output; import requires --host and --port. Use --password-stdin or --password-env to avoid command-line passwords.";
- public static final String
- LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_RESTORE_STATS_BFF9FDC2 =
- "pipe-logical-backup ";
+ public static final String LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_6D62F9CE =
+ "pipe-logical-backup ";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_ENTRY_IS_UNSAFE_ARG_3E548152 =
"Logical backup archive entry is unsafe: %s";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_EXCEEDS_SAFETY_LIMIT_FFC54432 =
diff --git a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
index 530b504b29ce..35346133f19e 100644
--- a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -126,9 +126,8 @@ private CliMessages() {}
public static final String
LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_4677380E =
"使用 --input 指定输入。导出还需要 --output;导入需要 --host 和 --port。请使用 --password-stdin 或 --password-env,避免密码出现在命令行中。";
- public static final String
- LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_RESTORE_STATS_BFF9FDC2 =
- "pipe-logical-backup ";
+ public static final String LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_6D62F9CE =
+ "pipe-logical-backup ";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_ENTRY_IS_UNSAFE_ARG_3E548152 =
"逻辑备份归档项路径不安全:%s";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_EXCEEDS_SAFETY_LIMIT_FFC54432 =
diff --git a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
index 5d80243f3adf..1d2315f17c04 100644
--- a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
+++ b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
@@ -80,8 +80,6 @@ public final class PipeLogicalBackupTool {
private static final String COMMAND_VERIFY = "verify";
private static final String COMMAND_EXPORT = "export";
private static final String COMMAND_IMPORT = "import";
- private static final String COMMAND_RESTORE = "restore";
- private static final String COMMAND_STATS = "stats";
private static final String OPTION_INPUT = "input";
private static final String OPTION_OUTPUT = "output";
private static final String OPTION_RESUME = "resume";
@@ -137,10 +135,7 @@ static int run(final String[] args) throws Exception {
case COMMAND_EXPORT:
return export(line);
case COMMAND_IMPORT:
- case COMMAND_RESTORE:
return importBackup(line);
- case COMMAND_STATS:
- return inspect(line);
default:
throw new ParseException(
String.format(
@@ -352,19 +347,13 @@ private static int importBackup(final CommandLine line) throws Exception {
handshake(client, streams.get(0).getManifest().timestampPrecision, user, password);
long importedGroups = 0;
for (final BackupStream stream : streams) {
- final String streamId = stream.getManifest().streamId;
- final long lastApplied = checkpointState.appliedSequences.getOrDefault(streamId, -1L);
- for (final EventGroup group : stream.getEventGroups()) {
- if (group.getLastSequence() <= lastApplied) {
- continue;
- }
- for (final LogicalBackupRecord record : group.getRequests()) {
- transfer(client, record.toTPipeTransferReq());
- }
- checkpointState.appliedSequences.put(streamId, group.getLastSequence());
- writeCheckpoint(checkpoint, checkpointState);
- importedGroups++;
- }
+ importedGroups +=
+ importStream(
+ checkpoint,
+ checkpointState,
+ stream,
+ request -> transfer(client, request),
+ state -> writeCheckpoint(checkpoint, state));
}
writeCheckpoint(checkpoint, checkpointState);
System.out.println(
@@ -377,6 +366,49 @@ private static int importBackup(final CommandLine line) throws Exception {
return 0;
}
+ static long importStream(
+ final Path checkpoint,
+ final ImportCheckpoint checkpointState,
+ final BackupStream stream,
+ final RequestTransfer requestTransfer,
+ final CheckpointPersister checkpointPersister)
+ throws Exception {
+ final String streamId = stream.getManifest().streamId;
+ final long lastApplied = checkpointState.appliedSequences.getOrDefault(streamId, -1L);
+ long importedGroups = 0;
+ for (final EventGroup group : stream.getEventGroups()) {
+ if (group.getLastSequence() <= lastApplied) {
+ continue;
+ }
+
+ int nextRequestIndex = 0;
+ if (checkpointState.inProgress == null) {
+ checkpointState.inProgress = ImportProgress.start(streamId, group);
+ checkpointPersister.persist(checkpointState);
+ } else if (checkpointState.inProgress.matches(streamId, group)) {
+ nextRequestIndex = checkpointState.inProgress.nextRequestIndex;
+ } else {
+ throw invalidCheckpoint(checkpoint);
+ }
+
+ final List requests = group.getRequests();
+ if (nextRequestIndex < 0 || nextRequestIndex > requests.size()) {
+ throw invalidCheckpoint(checkpoint);
+ }
+ for (int index = nextRequestIndex; index < requests.size(); index++) {
+ requestTransfer.transfer(requests.get(index).toTPipeTransferReq());
+ checkpointState.inProgress.nextRequestIndex = index + 1;
+ checkpointPersister.persist(checkpointState);
+ }
+
+ checkpointState.appliedSequences.put(streamId, group.getLastSequence());
+ checkpointState.inProgress = null;
+ checkpointPersister.persist(checkpointState);
+ importedGroups++;
+ }
+ return importedGroups;
+ }
+
private static List read(final CommandLine line) throws IOException {
final String source = input(line);
try (final BackupInput input = BackupInput.open(Path.of(source))) {
@@ -472,14 +504,15 @@ private static void handshake(
}
}
- private static void transfer(final IoTDBSyncClient client, final TPipeTransferReq request)
+ static void transfer(final IoTDBSyncClient client, final TPipeTransferReq request)
throws TException, IOException {
final TPipeTransferResp response = client.pipeTransfer(request);
if (response == null
|| response.getStatus() == null
|| (response.getStatus().getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()
+ && response.getStatus().getCode() != TSStatusCode.REDIRECTION_RECOMMEND.getStatusCode()
&& response.getStatus().getCode()
- != TSStatusCode.REDIRECTION_RECOMMEND.getStatusCode())) {
+ != TSStatusCode.PIPE_RECEIVER_IDEMPOTENT_CONFLICT_EXCEPTION.getStatusCode())) {
throw new IOException(
String.format(
CliMessages.EXCEPTION_LOGICAL_BACKUP_REQUEST_TYPE_ARG_FAILED_ARG_75EE2D11,
@@ -497,7 +530,7 @@ private static Path checkpointPath(final CommandLine line) throws IOException {
return source.resolveSibling(source.getFileName() + ".import.checkpoint.json");
}
- private static ImportCheckpoint readCheckpoint(
+ static ImportCheckpoint readCheckpoint(
final Path checkpoint,
final List streams,
final String host,
@@ -539,7 +572,7 @@ private static ImportCheckpoint readCheckpoint(
|| !host.equals(state.targetHost)
|| port != state.targetPort
|| !user.equals(state.targetUser)
- || !checkpointSequencesAreValid(streams, state.appliedSequences)) {
+ || !checkpointStateIsValid(streams, state)) {
throw new IOException(
String.format(
CliMessages
@@ -615,34 +648,66 @@ private static void updateDigest(final MessageDigest digest, final long value) {
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array());
}
- private static boolean checkpointSequencesAreValid(
- final List streams, final Map appliedSequences) {
+ private static boolean checkpointStateIsValid(
+ final List streams, final ImportCheckpoint state) {
+ final Map appliedSequences = state.appliedSequences;
if (appliedSequences.size() > streams.size()) {
return false;
}
+ boolean foundIncompleteStream = false;
+ boolean matchedInProgress = false;
for (final BackupStream stream : streams) {
- if (!appliedSequences.containsKey(stream.getManifest().streamId)) {
- continue;
- }
- final Long applied = appliedSequences.get(stream.getManifest().streamId);
- if (applied == null) {
+ final String streamId = stream.getManifest().streamId;
+ final Long applied = appliedSequences.getOrDefault(streamId, -1L);
+ if (applied == null || applied < -1) {
return false;
}
- if (applied == -1) {
- continue;
+
+ int appliedGroupIndex = -1;
+ if (applied != -1) {
+ for (int index = 0; index < stream.getEventGroups().size(); index++) {
+ if (stream.getEventGroups().get(index).getLastSequence() == applied) {
+ appliedGroupIndex = index;
+ break;
+ }
+ }
+ if (appliedGroupIndex < 0) {
+ return false;
+ }
}
- if (stream.getEventGroups().stream().noneMatch(group -> group.getLastSequence() == applied)) {
+
+ final boolean streamComplete = appliedGroupIndex == stream.getEventGroups().size() - 1;
+ if (foundIncompleteStream && appliedGroupIndex >= 0) {
return false;
}
+ if (!streamComplete && !foundIncompleteStream) {
+ foundIncompleteStream = true;
+ if (state.inProgress != null) {
+ final EventGroup nextGroup = stream.getEventGroups().get(appliedGroupIndex + 1);
+ if (!state.inProgress.matches(streamId, nextGroup)
+ || state.inProgress.nextRequestIndex < 0
+ || state.inProgress.nextRequestIndex > nextGroup.getRequests().size()) {
+ return false;
+ }
+ matchedInProgress = true;
+ }
+ }
}
- return appliedSequences.keySet().stream()
- .allMatch(
- streamId ->
- streams.stream()
- .anyMatch(stream -> stream.getManifest().streamId.equals(streamId)));
+ return (state.inProgress == null || matchedInProgress)
+ && appliedSequences.keySet().stream()
+ .allMatch(
+ streamId ->
+ streams.stream()
+ .anyMatch(stream -> stream.getManifest().streamId.equals(streamId)));
+ }
+
+ private static IOException invalidCheckpoint(final Path checkpoint) {
+ return new IOException(
+ String.format(
+ CliMessages.EXCEPTION_LOGICAL_BACKUP_CHECKPOINT_IS_INVALID_ARG_71E82F4C, checkpoint));
}
- private static void writeCheckpoint(final Path checkpoint, final ImportCheckpoint checkpointState)
+ static void writeCheckpoint(final Path checkpoint, final ImportCheckpoint checkpointState)
throws IOException {
final Path parent = checkpoint.toAbsolutePath().normalize().getParent();
if (parent != null) {
@@ -681,7 +746,7 @@ private static void printUsage() {
formatter.printHelp(
new PrintWriter(System.out),
120,
- CliMessages.LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_RESTORE_STATS_BFF9FDC2,
+ CliMessages.LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_6D62F9CE,
CliMessages
.LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_4677380E,
optionsForHelp(),
@@ -710,14 +775,46 @@ private static Options optionsForHelp() {
return options;
}
- private static class ImportCheckpoint {
- private String formatName;
- private String formatVersion;
- private Map sourceStreams = new LinkedHashMap<>();
- private String targetHost;
- private int targetPort;
- private String targetUser;
- private Map appliedSequences = new LinkedHashMap<>();
+ static class ImportCheckpoint {
+ String formatName;
+ String formatVersion;
+ Map sourceStreams = new LinkedHashMap<>();
+ String targetHost;
+ int targetPort;
+ String targetUser;
+ Map appliedSequences = new LinkedHashMap<>();
+ ImportProgress inProgress;
+ }
+
+ static class ImportProgress {
+ String streamId;
+ String eventGroupId;
+ long lastSequence;
+ int nextRequestIndex;
+
+ static ImportProgress start(final String streamId, final EventGroup group) {
+ final ImportProgress progress = new ImportProgress();
+ progress.streamId = streamId;
+ progress.eventGroupId = group.getEventGroupId().toString();
+ progress.lastSequence = group.getLastSequence();
+ return progress;
+ }
+
+ boolean matches(final String candidateStreamId, final EventGroup group) {
+ return candidateStreamId.equals(streamId)
+ && group.getEventGroupId().toString().equals(eventGroupId)
+ && group.getLastSequence() == lastSequence;
+ }
+ }
+
+ @FunctionalInterface
+ interface RequestTransfer {
+ void transfer(TPipeTransferReq request) throws Exception;
+ }
+
+ @FunctionalInterface
+ interface CheckpointPersister {
+ void persist(ImportCheckpoint state) throws IOException;
}
private static class BackupInput implements AutoCloseable {
diff --git a/iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java b/iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java
index 0fe45b22d897..6e7af8726ab3 100644
--- a/iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java
+++ b/iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java
@@ -19,27 +19,35 @@
package org.apache.iotdb.tool.pipe;
+import org.apache.iotdb.common.rpc.thrift.TSStatus;
+import org.apache.iotdb.commons.pipe.sink.client.IoTDBSyncClient;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader.BackupStream;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupFormat;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupManifest;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupWriter;
+import org.apache.iotdb.rpc.TSStatusCode;
import org.apache.iotdb.service.rpc.thrift.TPipeTransferReq;
+import org.apache.iotdb.service.rpc.thrift.TPipeTransferResp;
import org.apache.commons.cli.ParseException;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
@@ -141,7 +149,93 @@ public void testPlaintextPasswordOptionIsRejected() throws Exception {
}
}
+ @Test
+ public void testRemovedCommandAliasesAreRejected() throws Exception {
+ final Path source = temporaryFolder.getRoot().toPath().resolve("source");
+ writeBackup(source);
+
+ for (final String command : Arrays.asList("restore", "stats")) {
+ try {
+ PipeLogicalBackupTool.run(new String[] {command, "--input", source.toString()});
+ Assert.fail();
+ } catch (final ParseException expected) {
+ // Expected.
+ }
+ }
+ }
+
+ @Test
+ public void testTransferAcceptsIdempotentConflict() throws Exception {
+ final IoTDBSyncClient client = Mockito.mock(IoTDBSyncClient.class);
+ final TPipeTransferReq request = request(1);
+ Mockito.when(client.pipeTransfer(request))
+ .thenReturn(
+ new TPipeTransferResp(
+ new TSStatus(
+ TSStatusCode.PIPE_RECEIVER_IDEMPOTENT_CONFLICT_EXCEPTION.getStatusCode())));
+
+ PipeLogicalBackupTool.transfer(client, request);
+ }
+
+ @Test
+ public void testImportCheckpointResumesAtRequestGranularity() throws Exception {
+ final Path root = temporaryFolder.getRoot().toPath();
+ final Path source = root.resolve("source");
+ writeBackup(source, Arrays.asList(request(1), request(2)));
+ final List streams = new LogicalBackupArchiveReader().read(source, false);
+ final BackupStream stream = streams.get(0);
+ final Path checkpoint = root.resolve("import.checkpoint.json");
+ final PipeLogicalBackupTool.ImportCheckpoint state =
+ PipeLogicalBackupTool.readCheckpoint(checkpoint, streams, "localhost", 6667, "root");
+ final List transferredBodies = new ArrayList<>();
+ final AtomicBoolean failCheckpointOnce = new AtomicBoolean(true);
+
+ try {
+ PipeLogicalBackupTool.importStream(
+ checkpoint,
+ state,
+ stream,
+ request -> transferredBodies.add((int) request.getBody()[0]),
+ checkpointState -> {
+ if (checkpointState.inProgress != null
+ && checkpointState.inProgress.nextRequestIndex == 1
+ && failCheckpointOnce.getAndSet(false)) {
+ throw new IOException("simulated checkpoint failure");
+ }
+ PipeLogicalBackupTool.writeCheckpoint(checkpoint, checkpointState);
+ });
+ Assert.fail();
+ } catch (final IOException expected) {
+ // The request succeeded, but advancing its checkpoint was interrupted.
+ }
+
+ Assert.assertEquals(Collections.singletonList(1), transferredBodies);
+ final PipeLogicalBackupTool.ImportCheckpoint durableState =
+ PipeLogicalBackupTool.readCheckpoint(checkpoint, streams, "localhost", 6667, "root");
+ Assert.assertNotNull(durableState.inProgress);
+ Assert.assertEquals(0, durableState.inProgress.nextRequestIndex);
+
+ Assert.assertEquals(
+ 1,
+ PipeLogicalBackupTool.importStream(
+ checkpoint,
+ durableState,
+ stream,
+ request -> transferredBodies.add((int) request.getBody()[0]),
+ checkpointState -> PipeLogicalBackupTool.writeCheckpoint(checkpoint, checkpointState)));
+ Assert.assertEquals(Arrays.asList(1, 1, 2), transferredBodies);
+ Assert.assertNull(durableState.inProgress);
+ Assert.assertEquals(
+ Long.valueOf(stream.getEventGroups().get(0).getLastSequence()),
+ durableState.appliedSequences.get(stream.getManifest().streamId));
+ }
+
private static void writeBackup(final Path directory) throws Exception {
+ writeBackup(directory, Collections.singletonList(request(3)));
+ }
+
+ private static void writeBackup(final Path directory, final List requests)
+ throws Exception {
final LogicalBackupManifest manifest = new LogicalBackupManifest();
manifest.backupId = "backup";
manifest.pipeName = "pipe";
@@ -152,15 +246,14 @@ private static void writeBackup(final Path directory) throws Exception {
try (final LogicalBackupWriter writer =
new LogicalBackupWriter(
directory, manifest, 4096, 1024, LogicalBackupWriter.FsyncPolicy.ALWAYS, 1, 1, false)) {
- writer.writeEvent(
- UUID.randomUUID(),
- 1,
- Collections.singletonList(
- new TPipeTransferReq()
- .setVersion((byte) 1)
- .setType((short) 10)
- .setBody(ByteBuffer.wrap(new byte[] {3}))),
- "metadata");
+ writer.writeEvent(UUID.randomUUID(), 1, requests, "metadata");
}
}
+
+ private static TPipeTransferReq request(final int value) {
+ return new TPipeTransferReq()
+ .setVersion((byte) 1)
+ .setType((short) 10)
+ .setBody(ByteBuffer.wrap(new byte[] {(byte) value}));
+ }
}
diff --git a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
index d530fc7cf1bd..3a914bc80c76 100644
--- a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
+++ b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
@@ -2647,6 +2647,9 @@ private DataNodePipeMessages() {}
"Unsupported logical backup event policy: %s";
public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_RESUME_POLICY_ARG_583FCCF9 =
"Unsupported logical backup resume policy: %s";
+ public static final String
+ EXCEPTION_DATAREGION_LOGICAL_BACKUP_REQUIRES_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_9F7ADEA8 =
+ "DataRegion logical backup requires explicit sink.resume=append (or connector.resume=append) and a shared backup directory";
public static final String EXCEPTION_UNSUPPORTED_PIPE_EVENT_FOR_LOGICAL_BACKUP_ARG_521AEE97 =
"Unsupported Pipe event for logical backup: %s";
public static final String
diff --git a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
index 49575e46abb9..08803638ea60 100644
--- a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
+++ b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
@@ -2471,6 +2471,9 @@ private DataNodePipeMessages() {}
"不支持的逻辑备份事件策略:%s";
public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_RESUME_POLICY_ARG_583FCCF9 =
"不支持的逻辑备份恢复策略:%s";
+ public static final String
+ EXCEPTION_DATAREGION_LOGICAL_BACKUP_REQUIRES_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_9F7ADEA8 =
+ "DataRegion 逻辑备份要求显式配置 sink.resume=append(或 connector.resume=append),并使用共享备份目录";
public static final String EXCEPTION_UNSUPPORTED_PIPE_EVENT_FOR_LOGICAL_BACKUP_ARG_521AEE97 =
"逻辑备份不支持 Pipe 事件:%s";
public static final String
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
index 594d49922f7e..ef152b58b7c3 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
@@ -160,6 +160,7 @@ public void validate(final PipeParameterValidator validator) throws Exception {
PipeSinkConstant.LOGICAL_BACKUP_FSYNC_BATCH,
PipeSinkConstant.LOGICAL_BACKUP_FSYNC_PERIODIC,
PipeSinkConstant.LOGICAL_BACKUP_FSYNC_NONE);
+ validateDataRegionResumePolicy(parameters);
final String policy =
parameters
.getStringOrDefault(
@@ -187,6 +188,29 @@ private static void validateSynonymAttributes(
false);
}
+ private void validateDataRegionResumePolicy(final PipeParameters parameters)
+ throws PipeParameterNotValidException {
+ if (!"data".equals(streamType)) {
+ return;
+ }
+ final boolean explicitlyConfigured =
+ parameters.hasAnyAttributes(
+ PipeSinkConstant.CONNECTOR_LOGICAL_BACKUP_RESUME_KEY,
+ PipeSinkConstant.SINK_LOGICAL_BACKUP_RESUME_KEY);
+ final String resume =
+ parameters.getStringOrDefault(
+ Arrays.asList(
+ PipeSinkConstant.CONNECTOR_LOGICAL_BACKUP_RESUME_KEY,
+ PipeSinkConstant.SINK_LOGICAL_BACKUP_RESUME_KEY),
+ PipeSinkConstant.LOGICAL_BACKUP_RESUME_DEFAULT_VALUE);
+ if (!explicitlyConfigured
+ || !PipeSinkConstant.LOGICAL_BACKUP_RESUME_APPEND.equalsIgnoreCase(resume)) {
+ throw new PipeParameterNotValidException(
+ DataNodePipeMessages
+ .EXCEPTION_DATAREGION_LOGICAL_BACKUP_REQUIRES_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_9F7ADEA8);
+ }
+ }
+
@Override
public void customize(
final PipeParameters parameters, final PipeConnectorRuntimeConfiguration configuration)
@@ -253,6 +277,7 @@ public void customize(
PipeSinkConstant.SINK_LOGICAL_BACKUP_RESUME_KEY),
PipeSinkConstant.LOGICAL_BACKUP_RESUME_DEFAULT_VALUE)
.toLowerCase(Locale.ROOT);
+ validateDataRegionResumePolicy(parameters);
final boolean append = PipeSinkConstant.LOGICAL_BACKUP_RESUME_APPEND.equals(resume);
if (!append
&& !PipeSinkConstant.LOGICAL_BACKUP_RESUME_NEW.equals(resume)
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSinkTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSinkTest.java
new file mode 100644
index 000000000000..37c3d622b4f5
--- /dev/null
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSinkTest.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.db.pipe.sink.protocol.logicalbackup;
+
+import org.apache.iotdb.commons.pipe.config.constant.PipeSinkConstant;
+import org.apache.iotdb.pipe.api.customizer.parameter.PipeParameterValidator;
+import org.apache.iotdb.pipe.api.customizer.parameter.PipeParameters;
+import org.apache.iotdb.pipe.api.exception.PipeParameterNotValidException;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class LogicalBackupSinkTest {
+
+ @Test
+ public void testDataRegionRequiresExplicitAppendResumePolicy() throws Exception {
+ final Map attributes = new HashMap<>();
+ attributes.put(PipeSinkConstant.SINK_LOGICAL_BACKUP_DIR_KEY, "backup");
+
+ assertInvalid(new LogicalBackupSink("data"), attributes);
+
+ attributes.put(
+ PipeSinkConstant.SINK_LOGICAL_BACKUP_RESUME_KEY,
+ PipeSinkConstant.LOGICAL_BACKUP_RESUME_FAIL_IF_EXISTS);
+ assertInvalid(new LogicalBackupSink("data"), attributes);
+
+ attributes.put(
+ PipeSinkConstant.SINK_LOGICAL_BACKUP_RESUME_KEY,
+ PipeSinkConstant.LOGICAL_BACKUP_RESUME_APPEND);
+ new LogicalBackupSink("data")
+ .validate(new PipeParameterValidator(new PipeParameters(attributes)));
+
+ attributes.remove(PipeSinkConstant.SINK_LOGICAL_BACKUP_RESUME_KEY);
+ attributes.put(
+ PipeSinkConstant.CONNECTOR_LOGICAL_BACKUP_RESUME_KEY,
+ PipeSinkConstant.LOGICAL_BACKUP_RESUME_APPEND);
+ new LogicalBackupSink("data")
+ .validate(new PipeParameterValidator(new PipeParameters(attributes)));
+ }
+
+ @Test
+ public void testSchemaRegionKeepsDefaultResumePolicy() throws Exception {
+ final Map attributes = new HashMap<>();
+ attributes.put(PipeSinkConstant.SINK_LOGICAL_BACKUP_DIR_KEY, "backup");
+
+ new LogicalBackupSink("schema")
+ .validate(new PipeParameterValidator(new PipeParameters(attributes)));
+ }
+
+ private static void assertInvalid(
+ final LogicalBackupSink sink, final Map attributes) throws Exception {
+ try {
+ sink.validate(new PipeParameterValidator(new PipeParameters(new HashMap<>(attributes))));
+ Assert.fail();
+ } catch (final PipeParameterNotValidException expected) {
+ Assert.assertTrue(expected.getMessage().contains("append"));
+ }
+ }
+}
diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriterTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriterTest.java
index b6806f3407be..ec7d5d63ed9f 100644
--- a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriterTest.java
+++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupWriterTest.java
@@ -91,6 +91,31 @@ public void testDuplicateEventWithDifferentDigestFails() throws Exception {
}
}
+ @Test
+ public void testAppendContinuesAndDeduplicatesLastEventAfterRestart() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ final UUID firstEventId = UUID.randomUUID();
+ final TPipeTransferReq firstRequest = replayableRequest(1);
+ try (final LogicalBackupWriter writer = writer(directory, 4096, false)) {
+ Assert.assertEquals(
+ 0, writer.writeEvent(firstEventId, 1, Collections.singletonList(firstRequest), "first"));
+ }
+
+ try (final LogicalBackupWriter writer = writer(directory, 4096, true)) {
+ Assert.assertEquals(
+ 0, writer.writeEvent(firstEventId, 1, Collections.singletonList(firstRequest), "first"));
+ Assert.assertEquals(
+ 4,
+ writer.writeEvent(
+ UUID.randomUUID(), 2, Collections.singletonList(replayableRequest(2)), "second"));
+ }
+
+ final List streams =
+ new LogicalBackupArchiveReader().read(directory, false);
+ Assert.assertEquals(1, streams.size());
+ Assert.assertEquals(2, streams.get(0).getEventGroups().size());
+ }
+
@Test
public void testOnlineRollbackAfterPartialEvent() throws Exception {
final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
@@ -522,6 +547,7 @@ private static LogicalBackupManifest manifest() {
manifest.pipeCreationTime = 1;
manifest.streamId = "stream";
manifest.streamType = "data";
+ manifest.timestampPrecision = "ms";
return manifest;
}
@@ -533,6 +559,10 @@ private static TPipeTransferReq request(final byte[] body) {
return request((byte) 1, (short) 2, body);
}
+ private static TPipeTransferReq replayableRequest(final int value) {
+ return request((byte) 1, (short) 10, new byte[] {(byte) value});
+ }
+
private static TPipeTransferReq request(final byte version, final short type, final byte[] body) {
return new TPipeTransferReq().setVersion(version).setType(type).setBody(ByteBuffer.wrap(body));
}
From 4451babdf117666b14245a04ea1561b1fdafbe0c Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Mon, 21 Sep 2026 16:43:51 +0800
Subject: [PATCH 4/8] Add managed logical backup export workflow
---
.../apache/iotdb/cli/i18n/CliMessages.java | 21 +-
.../apache/iotdb/cli/i18n/CliMessages.java | 21 +-
.../tool/pipe/PipeLogicalBackupTool.java | 228 ++++++++++++++++--
3 files changed, 250 insertions(+), 20 deletions(-)
diff --git a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
index 8aa2feccc907..064dd16db8b0 100644
--- a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -128,8 +128,8 @@ private CliMessages() {}
public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_TYPE_ARG_FAILED_ARG_75EE2D11 =
"Logical backup request type %d failed: %s";
public static final String
- LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_4677380E =
- "Use --input to specify the input. Export also requires --output; import requires --host and --port. Use --password-stdin or --password-env to avoid command-line passwords.";
+ LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_EXPORT_CAN_USE_BACKUP_DIR_TO_CREATE_MONITOR_AND_CLEAN_UP_A_SNAPSHOT_PIPE_AUTOMATICALLY_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_F5B223CB =
+ "Use --input to specify the input. Export also requires --output; export can use --backup-dir to create, monitor, and clean up a snapshot Pipe automatically. Import requires --host and --port. Use --password-stdin or --password-env to avoid command-line passwords.";
public static final String LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_6D62F9CE =
"pipe-logical-backup ";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_ENTRY_IS_UNSAFE_ARG_3E548152 =
@@ -151,5 +151,22 @@ private CliMessages() {}
"Logical backup checkpoint is invalid: %s";
public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_EXPORT_FORMAT_ARG_A6D7DEB1 =
"Unsupported logical backup export format: %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EXPORT_TIMEOUT_AND_POLL_INTERVAL_MUST_BE_POSITIVE_38622769 =
+ "Logical backup export timeout and poll interval must be positive";
+ public static final String
+ LOG_LOGICAL_BACKUP_EXPORT_ARG_STATE_ARG_REMAINING_ARG_ESTIMATED_SECONDS_ARG_6256B85B =
+ "Logical backup export %s: state=%s, remaining=%s, estimated_seconds=%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_EXPORT_PIPE_ARG_FAILED_ARG_F52EB190 =
+ "Logical backup export pipe %s failed: %s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EXPORT_PIPE_ARG_TIMED_OUT_AFTER_ARG_SECONDS_76CF4B4B =
+ "Logical backup export pipe %s timed out after %d seconds";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_MUST_BE_AN_INTEGER_C9B397BE =
+ "Logical backup option --%s must be an integer";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_HAS_INVALID_IDENTIFIER_ARG_BB015204 =
+ "Logical backup option --%s has invalid identifier: %s";
}
diff --git a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
index 35346133f19e..d08e2053fa8c 100644
--- a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -124,8 +124,8 @@ private CliMessages() {}
public static final String EXCEPTION_LOGICAL_BACKUP_REQUEST_TYPE_ARG_FAILED_ARG_75EE2D11 =
"逻辑备份请求类型 %d 执行失败:%s";
public static final String
- LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_4677380E =
- "使用 --input 指定输入。导出还需要 --output;导入需要 --host 和 --port。请使用 --password-stdin 或 --password-env,避免密码出现在命令行中。";
+ LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_EXPORT_CAN_USE_BACKUP_DIR_TO_CREATE_MONITOR_AND_CLEAN_UP_A_SNAPSHOT_PIPE_AUTOMATICALLY_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_F5B223CB =
+ "使用 --input 指定输入。导出还需要 --output;导出也可使用 --backup-dir 自动创建、监控并清理快照 Pipe。导入需要 --host 和 --port。请使用 --password-stdin 或 --password-env,避免密码出现在命令行中。";
public static final String LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_6D62F9CE =
"pipe-logical-backup ";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_ENTRY_IS_UNSAFE_ARG_3E548152 =
@@ -148,4 +148,21 @@ private CliMessages() {}
public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_EXPORT_FORMAT_ARG_A6D7DEB1 =
"不支持的逻辑备份导出格式:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EXPORT_TIMEOUT_AND_POLL_INTERVAL_MUST_BE_POSITIVE_38622769 =
+ "逻辑备份导出 timeout 和轮询间隔必须为正数";
+ public static final String
+ LOG_LOGICAL_BACKUP_EXPORT_ARG_STATE_ARG_REMAINING_ARG_ESTIMATED_SECONDS_ARG_6256B85B =
+ "逻辑备份导出 %s:状态=%s,剩余=%s,预计秒数=%s";
+ public static final String EXCEPTION_LOGICAL_BACKUP_EXPORT_PIPE_ARG_FAILED_ARG_F52EB190 =
+ "逻辑备份导出 pipe %s 失败:%s";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_EXPORT_PIPE_ARG_TIMED_OUT_AFTER_ARG_SECONDS_76CF4B4B =
+ "逻辑备份导出 pipe %s 在 %d 秒后超时";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_MUST_BE_AN_INTEGER_C9B397BE =
+ "逻辑备份选项 --%s 必须是整数";
+ public static final String
+ EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_HAS_INVALID_IDENTIFIER_ARG_BB015204 =
+ "逻辑备份选项 --%s 的标识符无效:%s";
}
diff --git a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
index 1d2315f17c04..218f36709317 100644
--- a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
+++ b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
@@ -30,9 +30,11 @@
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupRecord;
import org.apache.iotdb.commons.pipe.sink.payload.thrift.common.PipeTransferHandshakeConstant;
import org.apache.iotdb.db.pipe.sink.payload.evolvable.request.PipeTransferDataNodeHandshakeV2Req;
+import org.apache.iotdb.isession.SessionDataSet;
import org.apache.iotdb.rpc.TSStatusCode;
import org.apache.iotdb.service.rpc.thrift.TPipeTransferReq;
import org.apache.iotdb.service.rpc.thrift.TPipeTransferResp;
+import org.apache.iotdb.session.Session;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
@@ -97,6 +99,11 @@ public final class PipeLogicalBackupTool {
private static final String OPTION_DRY_RUN = "dry-run";
private static final String OPTION_ALLOW_INCOMPLETE = "allow-incomplete";
private static final String OPTION_JSON = "json";
+ private static final String OPTION_BACKUP_DIR = "backup-dir";
+ private static final String OPTION_PIPE_NAME = "pipe-name";
+ private static final String OPTION_BACKUP_ID = "backup-id";
+ private static final String OPTION_TIMEOUT_SECONDS = "timeout-seconds";
+ private static final String OPTION_POLL_INTERVAL_SECONDS = "poll-interval-seconds";
private static final int MAX_ARCHIVE_ENTRIES = 1_000_000;
private static final long MAX_ARCHIVE_BYTES = 4L * 1024 * 1024 * 1024 * 1024;
private static final String CHECKPOINT_FORMAT_NAME =
@@ -162,9 +169,15 @@ private static CommandLine parse(final String command, final String[] args)
addOption(options, OPTION_DRY_RUN, false, false);
addOption(options, OPTION_ALLOW_INCOMPLETE, false, false);
addOption(options, OPTION_JSON, false, false);
+ addOption(options, OPTION_BACKUP_DIR, true, false);
+ addOption(options, OPTION_PIPE_NAME, true, false);
+ addOption(options, OPTION_BACKUP_ID, true, false);
+ addOption(options, OPTION_TIMEOUT_SECONDS, true, false);
+ addOption(options, OPTION_POLL_INTERVAL_SECONDS, true, false);
final CommandLine line = new DefaultParser().parse(options, args);
final String input = optionValue(line, OPTION_INPUT, OPTION_SOURCE);
- if (input == null || input.isBlank()) {
+ if ((input == null || input.isBlank())
+ && (!COMMAND_EXPORT.equals(command) || !line.hasOption(OPTION_BACKUP_DIR))) {
throw new ParseException(
String.format(
CliMessages.EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_IS_REQUIRED_1E7449AA, OPTION_INPUT));
@@ -231,32 +244,108 @@ private static int verify(final CommandLine line) throws IOException {
return 0;
}
- private static int export(final CommandLine line) throws IOException {
+ private static int export(final CommandLine line) throws Exception {
+ if (!line.hasOption(OPTION_INPUT) && !line.hasOption(OPTION_SOURCE)) {
+ return managedExport(line);
+ }
final Path source = Path.of(input(line)).toAbsolutePath().normalize();
final Path target =
Path.of(optionValue(line, OPTION_OUTPUT, OPTION_TARGET)).toAbsolutePath().normalize();
final List streams = read(line);
+ return exportArchive(source, target, streams);
+ }
+
+ private static int managedExport(final CommandLine line) throws Exception {
+ final String host = required(line, OPTION_HOST);
+ final int port = Integer.parseInt(required(line, OPTION_PORT));
+ final String user = line.getOptionValue(OPTION_USER, "root");
+ final String password = password(line);
+ final Path backupRoot = Path.of(required(line, OPTION_BACKUP_DIR)).toAbsolutePath().normalize();
+ final Path target =
+ Path.of(optionValue(line, OPTION_OUTPUT, OPTION_TARGET)).toAbsolutePath().normalize();
+ final String pipeName =
+ line.getOptionValue(
+ OPTION_PIPE_NAME,
+ "logical_backup_export_" + UUID.randomUUID().toString().replace('-', '_'));
+ validateIdentifier(pipeName, OPTION_PIPE_NAME);
+ final String backupId = line.getOptionValue(OPTION_BACKUP_ID, pipeName);
+ validateIdentifier(backupId, OPTION_BACKUP_ID);
+ final long timeoutSeconds = longOption(line, OPTION_TIMEOUT_SECONDS, 3600L);
+ final long pollIntervalSeconds = longOption(line, OPTION_POLL_INTERVAL_SECONDS, 2L);
+ if (timeoutSeconds <= 0 || pollIntervalSeconds <= 0) {
+ throw new IOException(
+ CliMessages
+ .EXCEPTION_LOGICAL_BACKUP_EXPORT_TIMEOUT_AND_POLL_INTERVAL_MUST_BE_POSITIVE_38622769);
+ }
+
+ final String createSql =
+ "CREATE PIPE "
+ + pipeName
+ + " WITH SOURCE ('source'='iotdb-source','source.mode'='snapshot','source.inclusion'='all',"
+ + "'source.capture.tree'='true','source.capture.table'='true',"
+ + "'source.realtime.enable'='false') WITH SINK ('sink'='logical-backup-sink','sink.dir'='"
+ + sqlString(backupRoot.toString())
+ + "','sink.backup-id'='"
+ + sqlString(backupId)
+ + "','sink.resume'='append')";
+ boolean created = false;
+ try (final Session session =
+ new Session.Builder().host(host).port(port).username(user).password(password).build()) {
+ session.open();
+ session.executeNonQueryStatement(createSql);
+ created = true;
+ session.executeNonQueryStatement("START PIPE " + pipeName);
+ waitForSnapshotPipe(session, pipeName, timeoutSeconds, pollIntervalSeconds);
+ final Path source = backupRoot.resolve(backupId).normalize();
+ final List streams = new LogicalBackupArchiveReader().read(source, false);
+ return exportArchive(source, target, streams);
+ } finally {
+ if (created) {
+ try (final Session cleanup =
+ new Session.Builder().host(host).port(port).username(user).password(password).build()) {
+ cleanup.open();
+ try {
+ cleanup.executeNonQueryStatement("STOP PIPE " + pipeName);
+ } catch (final Exception ignored) {
+ // A completed snapshot pipe may already have been auto-dropped.
+ }
+ try {
+ cleanup.executeNonQueryStatement("DROP PIPE " + pipeName);
+ } catch (final Exception ignored) {
+ // Keep the original export failure when cleanup races with auto-drop.
+ }
+ } catch (final Exception ignored) {
+ // The archive is complete once the generated pipe disappears; cleanup is best effort.
+ }
+ }
+ }
+ }
+
+ private static int exportArchive(
+ final Path source, final Path target, final List streams) throws IOException {
+ final Path normalizedSource = source.toAbsolutePath().normalize();
+ final Path normalizedTarget = target.toAbsolutePath().normalize();
final Path root;
- if (Files.isDirectory(source)) {
- root = source;
- } else if (Files.isRegularFile(source)
- && "manifest.json".equals(source.getFileName().toString())) {
- root = source.getParent();
+ if (Files.isDirectory(normalizedSource)) {
+ root = normalizedSource;
+ } else if (Files.isRegularFile(normalizedSource)
+ && "manifest.json".equals(normalizedSource.getFileName().toString())) {
+ root = normalizedSource.getParent();
} else {
throw new IOException(
String.format(
CliMessages
.EXCEPTION_LOGICAL_BACKUP_EXPORT_SOURCE_MUST_BE_A_DIRECTORY_OR_MANIFEST_ARG_0BBDE9F0,
- source));
+ normalizedSource));
}
- if (target.startsWith(root)) {
+ if (normalizedTarget.startsWith(root)) {
throw new IOException(
CliMessages.EXCEPTION_LOGICAL_BACKUP_EXPORT_TARGET_MUST_NOT_BE_INSIDE_SOURCE_A499F994);
}
- if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
- throw new java.nio.file.FileAlreadyExistsException(target.toString());
+ if (Files.exists(normalizedTarget, LinkOption.NOFOLLOW_LINKS)) {
+ throw new java.nio.file.FileAlreadyExistsException(normalizedTarget.toString());
}
- final Path parent = target.toAbsolutePath().normalize().getParent();
+ final Path parent = normalizedTarget.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
@@ -276,9 +365,9 @@ private static int export(final CommandLine line) throws IOException {
archiveChannel.force(true);
}
try {
- Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE);
+ Files.move(temporary, normalizedTarget, StandardCopyOption.ATOMIC_MOVE);
} catch (final java.nio.file.AtomicMoveNotSupportedException e) {
- Files.move(temporary, target);
+ Files.move(temporary, normalizedTarget);
}
published = true;
} finally {
@@ -288,10 +377,112 @@ private static int export(final CommandLine line) throws IOException {
}
System.out.println(
String.format(
- CliMessages.LOG_LOGICAL_BACKUP_EXPORTED_FROM_ARG_TO_ARG_B3E8D280, source, target));
+ CliMessages.LOG_LOGICAL_BACKUP_EXPORTED_FROM_ARG_TO_ARG_B3E8D280,
+ normalizedSource,
+ normalizedTarget));
return 0;
}
+ private static void waitForSnapshotPipe(
+ final Session session,
+ final String pipeName,
+ final long timeoutSeconds,
+ final long pollSeconds)
+ throws Exception {
+ final long deadline = System.nanoTime() + timeoutSeconds * 1_000_000_000L;
+ while (System.nanoTime() < deadline) {
+ final PipeStatus status = readPipeStatus(session, pipeName);
+ if (status == null) {
+ return;
+ } else {
+ System.out.println(
+ String.format(
+ CliMessages
+ .LOG_LOGICAL_BACKUP_EXPORT_ARG_STATE_ARG_REMAINING_ARG_ESTIMATED_SECONDS_ARG_6256B85B,
+ pipeName,
+ status.state,
+ status.remainingEvents,
+ status.estimatedSeconds));
+ if (status.state != null
+ && (status.state.equalsIgnoreCase("ERROR")
+ || status.state.equalsIgnoreCase("STOPPED"))) {
+ throw new IOException(
+ String.format(
+ CliMessages.EXCEPTION_LOGICAL_BACKUP_EXPORT_PIPE_ARG_FAILED_ARG_F52EB190,
+ pipeName,
+ status.exceptionMessage));
+ }
+ }
+ Thread.sleep(pollSeconds * 1000L);
+ }
+ throw new IOException(
+ String.format(
+ CliMessages
+ .EXCEPTION_LOGICAL_BACKUP_EXPORT_PIPE_ARG_TIMED_OUT_AFTER_ARG_SECONDS_76CF4B4B,
+ pipeName,
+ timeoutSeconds));
+ }
+
+ private static PipeStatus readPipeStatus(final Session session, final String pipeName)
+ throws Exception {
+ try (final SessionDataSet dataSet = session.executeQueryStatement("SHOW PIPE " + pipeName)) {
+ final SessionDataSet.DataIterator iterator = dataSet.iterator();
+ if (!iterator.next()) {
+ return null;
+ }
+ final PipeStatus status = new PipeStatus();
+ status.state = value(iterator, dataSet, "State");
+ status.remainingEvents = value(iterator, dataSet, "RemainingEventCount");
+ status.estimatedSeconds = value(iterator, dataSet, "EstimatedRemainingSeconds");
+ status.exceptionMessage = value(iterator, dataSet, "ExceptionMessage");
+ return status;
+ }
+ }
+
+ private static String value(
+ final SessionDataSet.DataIterator iterator, final SessionDataSet dataSet, final String name)
+ throws Exception {
+ for (final String column : dataSet.getColumnNames()) {
+ if (column.equalsIgnoreCase(name)) {
+ return iterator.getString(column);
+ }
+ }
+ return "Unknown";
+ }
+
+ private static long longOption(final CommandLine line, final String name, final long fallback)
+ throws ParseException {
+ try {
+ return Long.parseLong(line.getOptionValue(name, Long.toString(fallback)));
+ } catch (final NumberFormatException e) {
+ throw new ParseException(
+ String.format(
+ CliMessages.EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_MUST_BE_AN_INTEGER_C9B397BE, name));
+ }
+ }
+
+ private static void validateIdentifier(final String value, final String option)
+ throws IOException {
+ if (!value.matches("[A-Za-z_][A-Za-z0-9_]{0,127}")) {
+ throw new IOException(
+ String.format(
+ CliMessages.EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_HAS_INVALID_IDENTIFIER_ARG_BB015204,
+ option,
+ value));
+ }
+ }
+
+ private static String sqlString(final String value) {
+ return value.replace("'", "''");
+ }
+
+ private static final class PipeStatus {
+ private String state;
+ private String remainingEvents;
+ private String estimatedSeconds;
+ private String exceptionMessage;
+ }
+
private static List exportFiles(final Path root, final List streams)
throws IOException {
final Set files = new LinkedHashSet<>();
@@ -748,7 +939,7 @@ private static void printUsage() {
120,
CliMessages.LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_6D62F9CE,
CliMessages
- .LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_4677380E,
+ .LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_EXPORT_CAN_USE_BACKUP_DIR_TO_CREATE_MONITOR_AND_CLEAN_UP_A_SNAPSHOT_PIPE_AUTOMATICALLY_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_F5B223CB,
optionsForHelp(),
2,
2,
@@ -772,6 +963,11 @@ private static Options optionsForHelp() {
addOption(options, OPTION_DRY_RUN, false, false);
addOption(options, OPTION_ALLOW_INCOMPLETE, false, false);
addOption(options, OPTION_JSON, false, false);
+ addOption(options, OPTION_BACKUP_DIR, true, false);
+ addOption(options, OPTION_PIPE_NAME, true, false);
+ addOption(options, OPTION_BACKUP_ID, true, false);
+ addOption(options, OPTION_TIMEOUT_SECONDS, true, false);
+ addOption(options, OPTION_POLL_INTERVAL_SECONDS, true, false);
return options;
}
From d38a79627b7c859eb3fcbae1f394aeb88443776b Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Tue, 22 Sep 2026 16:01:26 +0800
Subject: [PATCH 5/8] Rename logical backup user-facing scripts
---
.../src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java | 2 +-
.../src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java | 2 +-
.../{export-pipe-logical-backup.sh => export-logical-backup.sh} | 2 +-
.../{import-pipe-logical-backup.sh => import-logical-backup.sh} | 2 +-
scripts/tools/{pipe-logical-backup.sh => logical-backup.sh} | 0
...export-pipe-logical-backup.bat => export-logical-backup.bat} | 2 +-
...import-pipe-logical-backup.bat => import-logical-backup.bat} | 2 +-
.../windows/{pipe-logical-backup.bat => logical-backup.bat} | 0
8 files changed, 6 insertions(+), 6 deletions(-)
rename scripts/tools/{export-pipe-logical-backup.sh => export-logical-backup.sh} (93%)
rename scripts/tools/{import-pipe-logical-backup.sh => import-logical-backup.sh} (93%)
rename scripts/tools/{pipe-logical-backup.sh => logical-backup.sh} (100%)
rename scripts/tools/windows/{export-pipe-logical-backup.bat => export-logical-backup.bat} (94%)
rename scripts/tools/windows/{import-pipe-logical-backup.bat => import-logical-backup.bat} (94%)
rename scripts/tools/windows/{pipe-logical-backup.bat => logical-backup.bat} (100%)
diff --git a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
index 064dd16db8b0..1316415682f5 100644
--- a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -131,7 +131,7 @@ private CliMessages() {}
LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_EXPORT_CAN_USE_BACKUP_DIR_TO_CREATE_MONITOR_AND_CLEAN_UP_A_SNAPSHOT_PIPE_AUTOMATICALLY_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_F5B223CB =
"Use --input to specify the input. Export also requires --output; export can use --backup-dir to create, monitor, and clean up a snapshot Pipe automatically. Import requires --host and --port. Use --password-stdin or --password-env to avoid command-line passwords.";
public static final String LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_6D62F9CE =
- "pipe-logical-backup ";
+ "logical-backup ";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_ENTRY_IS_UNSAFE_ARG_3E548152 =
"Logical backup archive entry is unsafe: %s";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_EXCEEDS_SAFETY_LIMIT_FFC54432 =
diff --git a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
index d08e2053fa8c..c26071d0bf5f 100644
--- a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -127,7 +127,7 @@ private CliMessages() {}
LOG_USE_INPUT_TO_SPECIFY_THE_INPUT_EXPORT_ALSO_REQUIRES_OUTPUT_EXPORT_CAN_USE_BACKUP_DIR_TO_CREATE_MONITOR_AND_CLEAN_UP_A_SNAPSHOT_PIPE_AUTOMATICALLY_IMPORT_REQUIRES_HOST_AND_PORT_USE_PASSWORD_STDIN_OR_PASSWORD_ENV_TO_AVOID_COMMAND_LINE_PASSWORDS_F5B223CB =
"使用 --input 指定输入。导出还需要 --output;导出也可使用 --backup-dir 自动创建、监控并清理快照 Pipe。导入需要 --host 和 --port。请使用 --password-stdin 或 --password-env,避免密码出现在命令行中。";
public static final String LOG_PIPE_LOGICAL_BACKUP_INSPECT_VERIFY_EXPORT_IMPORT_6D62F9CE =
- "pipe-logical-backup ";
+ "logical-backup ";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_ENTRY_IS_UNSAFE_ARG_3E548152 =
"逻辑备份归档项路径不安全:%s";
public static final String EXCEPTION_LOGICAL_BACKUP_ARCHIVE_EXCEEDS_SAFETY_LIMIT_FFC54432 =
diff --git a/scripts/tools/export-pipe-logical-backup.sh b/scripts/tools/export-logical-backup.sh
similarity index 93%
rename from scripts/tools/export-pipe-logical-backup.sh
rename to scripts/tools/export-logical-backup.sh
index 8a0a4f8a1834..fcc072bb5c1e 100644
--- a/scripts/tools/export-pipe-logical-backup.sh
+++ b/scripts/tools/export-logical-backup.sh
@@ -18,4 +18,4 @@
# under the License.
#
-exec "$(dirname "$0")/pipe-logical-backup.sh" export "$@"
+exec "$(dirname "$0")/logical-backup.sh" export "$@"
diff --git a/scripts/tools/import-pipe-logical-backup.sh b/scripts/tools/import-logical-backup.sh
similarity index 93%
rename from scripts/tools/import-pipe-logical-backup.sh
rename to scripts/tools/import-logical-backup.sh
index 6e4e069210de..ebd38227a289 100644
--- a/scripts/tools/import-pipe-logical-backup.sh
+++ b/scripts/tools/import-logical-backup.sh
@@ -18,4 +18,4 @@
# under the License.
#
-exec "$(dirname "$0")/pipe-logical-backup.sh" import "$@"
+exec "$(dirname "$0")/logical-backup.sh" import "$@"
diff --git a/scripts/tools/pipe-logical-backup.sh b/scripts/tools/logical-backup.sh
similarity index 100%
rename from scripts/tools/pipe-logical-backup.sh
rename to scripts/tools/logical-backup.sh
diff --git a/scripts/tools/windows/export-pipe-logical-backup.bat b/scripts/tools/windows/export-logical-backup.bat
similarity index 94%
rename from scripts/tools/windows/export-pipe-logical-backup.bat
rename to scripts/tools/windows/export-logical-backup.bat
index 42e1df8afb27..d79ce5a43d98 100644
--- a/scripts/tools/windows/export-pipe-logical-backup.bat
+++ b/scripts/tools/windows/export-logical-backup.bat
@@ -17,5 +17,5 @@
@REM under the License.
@REM
-@call "%~dp0pipe-logical-backup.bat" export %*
+@call "%~dp0logical-backup.bat" export %*
@exit /B %ERRORLEVEL%
diff --git a/scripts/tools/windows/import-pipe-logical-backup.bat b/scripts/tools/windows/import-logical-backup.bat
similarity index 94%
rename from scripts/tools/windows/import-pipe-logical-backup.bat
rename to scripts/tools/windows/import-logical-backup.bat
index 0fba332d5a2e..bfe85ba3943f 100644
--- a/scripts/tools/windows/import-pipe-logical-backup.bat
+++ b/scripts/tools/windows/import-logical-backup.bat
@@ -17,5 +17,5 @@
@REM under the License.
@REM
-@call "%~dp0pipe-logical-backup.bat" import %*
+@call "%~dp0logical-backup.bat" import %*
@exit /B %ERRORLEVEL%
diff --git a/scripts/tools/windows/pipe-logical-backup.bat b/scripts/tools/windows/logical-backup.bat
similarity index 100%
rename from scripts/tools/windows/pipe-logical-backup.bat
rename to scripts/tools/windows/logical-backup.bat
From bc0a6e2d6249ec4ae9afb0232b4cb7fd5865ab4f Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Thu, 24 Sep 2026 20:19:59 +0800
Subject: [PATCH 6/8] Add ConfigNode logical backup support
---
.../tool/pipe/PipeLogicalBackupTool.java | 42 ++++
.../tool/pipe/PipeLogicalBackupToolTest.java | 39 +++-
.../PipeConfigRegionSinkConstructor.java | 3 +
.../logicalbackup/LogicalBackupSink.java | 183 ++++++++++++++++++
.../PipeConfigRegionSinkConstructorTest.java | 49 +++++
.../sink/PipeConfigNodeThriftRequestTest.java | 7 +
.../iotdb/db/i18n/DataNodePipeMessages.java | 4 +-
.../iotdb/db/i18n/DataNodePipeMessages.java | 4 +-
.../logicalbackup/LogicalBackupSink.java | 32 +--
.../logicalbackup/LogicalBackupSinkTest.java | 14 ++
.../plugin/builtin/BuiltinPipePlugin.java | 2 +-
.../LogicalBackupArchiveReader.java | 18 +-
.../request/PipeTransferHandshakeV2Req.java | 29 ++-
.../LogicalBackupArchiveReaderTest.java | 43 +++-
14 files changed, 430 insertions(+), 39 deletions(-)
create mode 100644 iotdb-core/confignode/src/main/java/org/apache/iotdb/confignode/manager/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
create mode 100644 iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/agent/plugin/PipeConfigRegionSinkConstructorTest.java
diff --git a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
index 218f36709317..631353027a61 100644
--- a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
+++ b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
@@ -29,6 +29,8 @@
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupManifest;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupRecord;
import org.apache.iotdb.commons.pipe.sink.payload.thrift.common.PipeTransferHandshakeConstant;
+import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeRequestType;
+import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeTransferHandshakeV2Req;
import org.apache.iotdb.db.pipe.sink.payload.evolvable.request.PipeTransferDataNodeHandshakeV2Req;
import org.apache.iotdb.isession.SessionDataSet;
import org.apache.iotdb.rpc.TSStatusCode;
@@ -536,6 +538,10 @@ private static int importBackup(final CommandLine line) throws Exception {
"",
"")) {
handshake(client, streams.get(0).getManifest().timestampPrecision, user, password);
+ if (containsConfigStream(streams)) {
+ configNodeHandshake(
+ client, streams.get(0).getManifest().timestampPrecision, user, password);
+ }
long importedGroups = 0;
for (final BackupStream stream : streams) {
importedGroups +=
@@ -557,6 +563,11 @@ private static int importBackup(final CommandLine line) throws Exception {
return 0;
}
+ static boolean containsConfigStream(final List streams) {
+ return streams.stream()
+ .anyMatch(stream -> "config".equalsIgnoreCase(stream.getManifest().streamType));
+ }
+
static long importStream(
final Path checkpoint,
final ImportCheckpoint checkpointState,
@@ -695,6 +706,37 @@ private static void handshake(
}
}
+ private static void configNodeHandshake(
+ final IoTDBSyncClient client,
+ final String timestampPrecision,
+ final String user,
+ final String password)
+ throws IOException, TException {
+ final Map params = new HashMap<>();
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_CLUSTER_ID, getClusterId());
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_TIME_PRECISION, timestampPrecision);
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_USER_ID, "-1");
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_USERNAME, user);
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_PASSWORD, password);
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_CLI_HOSTNAME, "pipe-logical-backup");
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_LOAD_TSFILE_STRATEGY, "sync");
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_VALIDATE_TSFILE, "false");
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_MARK_AS_PIPE_REQUEST, "true");
+ params.put(PipeTransferHandshakeConstant.HANDSHAKE_KEY_SKIP_IF, "false");
+ final TPipeTransferResp response =
+ client.pipeTransfer(
+ PipeTransferHandshakeV2Req.toTPipeTransferReq(
+ PipeRequestType.HANDSHAKE_CONFIGNODE_V2, params));
+ if (response == null
+ || response.getStatus() == null
+ || response.getStatus().getCode() != TSStatusCode.SUCCESS_STATUS.getStatusCode()) {
+ throw new IOException(
+ String.format(
+ CliMessages.EXCEPTION_LOGICAL_BACKUP_HANDSHAKE_FAILED_ARG_7CDD4697,
+ response == null ? "null" : response.getStatus()));
+ }
+ }
+
static void transfer(final IoTDBSyncClient client, final TPipeTransferReq request)
throws TException, IOException {
final TPipeTransferResp response = client.pipeTransfer(request);
diff --git a/iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java b/iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java
index 6e7af8726ab3..f39b87f0754e 100644
--- a/iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java
+++ b/iotdb-client/cli/src/test/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupToolTest.java
@@ -116,6 +116,22 @@ public void testCheckpointSourceIdentityIncludesContent() throws Exception {
Assert.assertNotEquals(before, after);
}
+ @Test
+ public void testConfigStreamDetection() throws Exception {
+ final Path root = temporaryFolder.getRoot().toPath();
+ final Path data = root.resolve("data");
+ final Path config = root.resolve("config");
+ writeBackup(data);
+ writeBackup(config, "config-0", "config", Collections.singletonList(request((short) 200)));
+
+ Assert.assertFalse(
+ PipeLogicalBackupTool.containsConfigStream(
+ new LogicalBackupArchiveReader().read(data, false)));
+ Assert.assertTrue(
+ PipeLogicalBackupTool.containsConfigStream(
+ new LogicalBackupArchiveReader().read(config, false)));
+ }
+
@Test
public void testZipSlipIsRejected() throws Exception {
final Path archive = temporaryFolder.getRoot().toPath().resolve("unsafe.zip");
@@ -236,12 +252,21 @@ private static void writeBackup(final Path directory) throws Exception {
private static void writeBackup(final Path directory, final List requests)
throws Exception {
+ writeBackup(directory, "data-1", "data", requests);
+ }
+
+ private static void writeBackup(
+ final Path directory,
+ final String streamId,
+ final String streamType,
+ final List requests)
+ throws Exception {
final LogicalBackupManifest manifest = new LogicalBackupManifest();
manifest.backupId = "backup";
manifest.pipeName = "pipe";
manifest.pipeCreationTime = 1;
- manifest.streamId = "data-1";
- manifest.streamType = "data";
+ manifest.streamId = streamId;
+ manifest.streamType = streamType;
manifest.timestampPrecision = "ms";
try (final LogicalBackupWriter writer =
new LogicalBackupWriter(
@@ -251,9 +276,17 @@ private static void writeBackup(final Path directory, final List requests = new ArrayList<>();
+ appendFile(requests, e.getSnapshotFile());
+ appendFile(requests, e.getTemplateFile());
+ final File snapshot = e.getSnapshotFile();
+ final File template = e.getTemplateFile();
+ requests.add(
+ PipeTransferConfigSnapshotSealReq.toTPipeTransferReq(
+ e.getTreePattern().getPattern(),
+ e.getTablePattern().getDatabasePattern(),
+ e.getTablePattern().getTablePattern(),
+ e.getTreePattern().isTreeModelDataAllowedToBeCaptured(),
+ e.getTablePattern().isTableModelDataAllowedToBeCaptured(),
+ snapshot.getName(),
+ snapshot.length(),
+ template == null ? null : template.getName(),
+ template == null ? 0 : template.length(),
+ e.getFileType(),
+ e.toSealTypeString(),
+ e.getAuthUserName()));
+ writer.writeEvent(
+ UUID.randomUUID(), System.currentTimeMillis(), requests, "config-snapshot");
+ } finally {
+ e.decreaseReferenceCount(getClass().getName(), false);
+ }
+ }
+ }
+ }
+
+ private static void appendFile(final List requests, final File file)
+ throws Exception {
+ if (file == null) return;
+ try (FileInputStream input = new FileInputStream(file)) {
+ final byte[] buffer = new byte[FILE_PIECE_BYTES];
+ long offset = 0;
+ int length;
+ while ((length = input.read(buffer)) > 0) {
+ requests.add(
+ PipeTransferConfigSnapshotPieceReq.toTPipeTransferReq(
+ file.getName(), offset, Arrays.copyOf(buffer, length)));
+ offset += length;
+ }
+ }
+ }
+
+ @Override
+ public void handshake() {}
+
+ @Override
+ public void heartbeat() throws Exception {
+ if (writer != null) writer.heartbeat();
+ }
+
+ @Override
+ public void close() throws Exception {
+ if (writer != null) {
+ writer.close();
+ writer = null;
+ }
+ }
+}
diff --git a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/agent/plugin/PipeConfigRegionSinkConstructorTest.java b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/agent/plugin/PipeConfigRegionSinkConstructorTest.java
new file mode 100644
index 000000000000..3031e2c6b216
--- /dev/null
+++ b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/agent/plugin/PipeConfigRegionSinkConstructorTest.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.confignode.manager.pipe.agent.plugin;
+
+import org.apache.iotdb.commons.pipe.config.constant.PipeSinkConstant;
+import org.apache.iotdb.confignode.manager.pipe.sink.protocol.logicalbackup.LogicalBackupSink;
+import org.apache.iotdb.pipe.api.customizer.parameter.PipeParameters;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Collections;
+
+public class PipeConfigRegionSinkConstructorTest {
+
+ @Test
+ public void testLogicalBackupAliasesUseConfigRegionSink() {
+ final PipeConfigRegionSinkConstructor constructor = new PipeConfigRegionSinkConstructor();
+
+ Assert.assertTrue(
+ constructor.reflectPlugin(
+ new PipeParameters(
+ Collections.singletonMap(
+ PipeSinkConstant.CONNECTOR_KEY, "logical-backup-connector")))
+ instanceof LogicalBackupSink);
+ Assert.assertTrue(
+ constructor.reflectPlugin(
+ new PipeParameters(
+ Collections.singletonMap(PipeSinkConstant.SINK_KEY, "logical-backup-sink")))
+ instanceof LogicalBackupSink);
+ }
+}
diff --git a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/sink/PipeConfigNodeThriftRequestTest.java b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/sink/PipeConfigNodeThriftRequestTest.java
index bd77d72d1bef..e8a17e5d524d 100644
--- a/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/sink/PipeConfigNodeThriftRequestTest.java
+++ b/iotdb-core/confignode/src/test/java/org/apache/iotdb/confignode/manager/pipe/sink/PipeConfigNodeThriftRequestTest.java
@@ -23,6 +23,7 @@
import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.IoTDBSinkRequestVersion;
import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeRequestType;
import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeTransferFileSealReqV2;
+import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeTransferHandshakeV2Req;
import org.apache.iotdb.commons.schema.column.ColumnHeaderConstant;
import org.apache.iotdb.confignode.consensus.request.write.cq.ActiveCQPlan;
import org.apache.iotdb.confignode.manager.pipe.sink.payload.PipeTransferConfigNodeHandshakeV1Req;
@@ -93,12 +94,18 @@ public void testPipeTransferConfigHandshakeV2Req() throws IOException {
final PipeTransferConfigNodeHandshakeV2Req req =
PipeTransferConfigNodeHandshakeV2Req.toTPipeTransferReq(params);
+ final TPipeTransferReq commonReq =
+ PipeTransferHandshakeV2Req.toTPipeTransferReq(
+ PipeRequestType.HANDSHAKE_CONFIGNODE_V2, params);
final int originalBodyPosition = req.body.position();
final PipeTransferConfigNodeHandshakeV2Req deserializeReq =
PipeTransferConfigNodeHandshakeV2Req.fromTPipeTransferReq(req);
Assert.assertEquals(req.getVersion(), deserializeReq.getVersion());
Assert.assertEquals(req.getType(), deserializeReq.getType());
+ Assert.assertEquals(req.getVersion(), commonReq.getVersion());
+ Assert.assertEquals(req.getType(), commonReq.getType());
+ Assert.assertArrayEquals(req.getBody(), commonReq.getBody());
Assert.assertEquals(originalBodyPosition, req.body.position());
Assert.assertEquals(params, deserializeReq.getParams());
}
diff --git a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
index 8c6200718838..5744a1bd34cf 100644
--- a/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
+++ b/iotdb-core/datanode/src/main/i18n/en/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
@@ -2654,8 +2654,8 @@ private DataNodePipeMessages() {}
public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_RESUME_POLICY_ARG_583FCCF9 =
"Unsupported logical backup resume policy: %s";
public static final String
- EXCEPTION_DATAREGION_LOGICAL_BACKUP_REQUIRES_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_9F7ADEA8 =
- "DataRegion logical backup requires explicit sink.resume=append (or connector.resume=append) and a shared backup directory";
+ EXCEPTION_CONFIGREGION_AND_DATAREGION_LOGICAL_BACKUP_REQUIRE_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_B041371B =
+ "ConfigRegion and DataRegion logical backup require explicit sink.resume=append (or connector.resume=append) and a shared backup directory";
public static final String EXCEPTION_UNSUPPORTED_PIPE_EVENT_FOR_LOGICAL_BACKUP_ARG_521AEE97 =
"Unsupported Pipe event for logical backup: %s";
public static final String
diff --git a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
index 44f7c0269199..d5200ee93dfa 100644
--- a/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
+++ b/iotdb-core/datanode/src/main/i18n/zh/org/apache/iotdb/db/i18n/DataNodePipeMessages.java
@@ -2478,8 +2478,8 @@ private DataNodePipeMessages() {}
public static final String EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_RESUME_POLICY_ARG_583FCCF9 =
"不支持的逻辑备份恢复策略:%s";
public static final String
- EXCEPTION_DATAREGION_LOGICAL_BACKUP_REQUIRES_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_9F7ADEA8 =
- "DataRegion 逻辑备份要求显式配置 sink.resume=append(或 connector.resume=append),并使用共享备份目录";
+ EXCEPTION_CONFIGREGION_AND_DATAREGION_LOGICAL_BACKUP_REQUIRE_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_B041371B =
+ "ConfigRegion 和 DataRegion 逻辑备份要求显式配置 sink.resume=append(或 connector.resume=append),并使用共享备份目录";
public static final String EXCEPTION_UNSUPPORTED_PIPE_EVENT_FOR_LOGICAL_BACKUP_ARG_521AEE97 =
"逻辑备份不支持 Pipe 事件:%s";
public static final String
diff --git a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
index ef152b58b7c3..c48238a5ce22 100644
--- a/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
+++ b/iotdb-core/datanode/src/main/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSink.java
@@ -75,12 +75,12 @@
public class LogicalBackupSink implements PipeConnector {
private static final int FILE_PIECE_BYTES = 64 * 1024;
- private LogicalBackupWriter writer;
+ protected LogicalBackupWriter writer;
private String pipeName;
private long creationTime;
private int regionId;
- private boolean includeHeartbeat;
- private String unsupportedEventPolicy;
+ protected boolean includeHeartbeat;
+ protected String unsupportedEventPolicy;
private final String streamType;
public LogicalBackupSink() {
@@ -160,7 +160,7 @@ public void validate(final PipeParameterValidator validator) throws Exception {
PipeSinkConstant.LOGICAL_BACKUP_FSYNC_BATCH,
PipeSinkConstant.LOGICAL_BACKUP_FSYNC_PERIODIC,
PipeSinkConstant.LOGICAL_BACKUP_FSYNC_NONE);
- validateDataRegionResumePolicy(parameters);
+ validateSharedDirectoryResumePolicy(parameters);
final String policy =
parameters
.getStringOrDefault(
@@ -188,9 +188,9 @@ private static void validateSynonymAttributes(
false);
}
- private void validateDataRegionResumePolicy(final PipeParameters parameters)
+ private void validateSharedDirectoryResumePolicy(final PipeParameters parameters)
throws PipeParameterNotValidException {
- if (!"data".equals(streamType)) {
+ if (!"data".equals(streamType) && !"config".equals(streamType)) {
return;
}
final boolean explicitlyConfigured =
@@ -207,7 +207,7 @@ private void validateDataRegionResumePolicy(final PipeParameters parameters)
|| !PipeSinkConstant.LOGICAL_BACKUP_RESUME_APPEND.equalsIgnoreCase(resume)) {
throw new PipeParameterNotValidException(
DataNodePipeMessages
- .EXCEPTION_DATAREGION_LOGICAL_BACKUP_REQUIRES_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_9F7ADEA8);
+ .EXCEPTION_CONFIGREGION_AND_DATAREGION_LOGICAL_BACKUP_REQUIRE_EXPLICIT_SINK_RESUME_APPEND_OR_CONNECTOR_RESUME_APPEND_AND_A_SHARED_BACKUP_DIRECTORY_B041371B);
}
}
@@ -277,7 +277,7 @@ public void customize(
PipeSinkConstant.SINK_LOGICAL_BACKUP_RESUME_KEY),
PipeSinkConstant.LOGICAL_BACKUP_RESUME_DEFAULT_VALUE)
.toLowerCase(Locale.ROOT);
- validateDataRegionResumePolicy(parameters);
+ validateSharedDirectoryResumePolicy(parameters);
final boolean append = PipeSinkConstant.LOGICAL_BACKUP_RESUME_APPEND.equals(resume);
if (!append
&& !PipeSinkConstant.LOGICAL_BACKUP_RESUME_NEW.equals(resume)
@@ -349,7 +349,7 @@ public void customize(
manifest.backupId = backupId;
manifest.pipeName = pipeName;
manifest.pipeCreationTime = creationTime;
- manifest.sourceClusterId = IoTDBDescriptor.getInstance().getConfig().getClusterId();
+ manifest.sourceClusterId = getSourceClusterId();
manifest.sourceVersion = IoTDBConstant.VERSION;
manifest.timestampPrecision =
CommonDescriptor.getInstance().getConfig().getTimestampPrecision();
@@ -369,6 +369,10 @@ public void customize(
append);
}
+ protected String getSourceClusterId() {
+ return IoTDBDescriptor.getInstance().getConfig().getClusterId();
+ }
+
@Override
public void handshake() {}
@@ -542,7 +546,7 @@ private List requests(final PipeInsertNodeTabletInsertionEvent
return requests;
}
- private void transferEnriched(final EnrichedEvent event, final RequestSupplier requestSupplier)
+ protected void transferEnriched(final EnrichedEvent event, final RequestSupplier requestSupplier)
throws Exception {
if (!event.increaseReferenceCount(getClass().getName())) {
return;
@@ -556,11 +560,11 @@ private void transferEnriched(final EnrichedEvent event, final RequestSupplier r
}
@FunctionalInterface
- private interface RequestSupplier {
+ protected interface RequestSupplier {
List get() throws Exception;
}
- private void handleUnsupported(final Event event) throws Exception {
+ protected void handleUnsupported(final Event event) throws Exception {
if (PipeSinkConstant.LOGICAL_BACKUP_UNSUPPORTED_EVENT_SKIP.equals(unsupportedEventPolicy)) {
writer.recordSkippedEvent(
System.currentTimeMillis(), event == null ? "null" : event.getClass().getName());
@@ -572,7 +576,7 @@ private void handleUnsupported(final Event event) throws Exception {
event == null ? "null" : event.getClass().getName()));
}
- private UUID eventId(final EnrichedEvent event) {
+ protected UUID eventId(final EnrichedEvent event) {
return UUID.nameUUIDFromBytes(
(event.getClass().getName()
+ '\0'
@@ -594,7 +598,7 @@ private String commitIds(final EnrichedEvent event) {
}
}
- private String metadata(final Event event) {
+ protected String metadata(final Event event) {
if (event instanceof EnrichedEvent) {
final EnrichedEvent enriched = (EnrichedEvent) event;
return "pipe="
diff --git a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSinkTest.java b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSinkTest.java
index 37c3d622b4f5..2354e8309ab9 100644
--- a/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSinkTest.java
+++ b/iotdb-core/datanode/src/test/java/org/apache/iotdb/db/pipe/sink/protocol/logicalbackup/LogicalBackupSinkTest.java
@@ -67,6 +67,20 @@ public void testSchemaRegionKeepsDefaultResumePolicy() throws Exception {
.validate(new PipeParameterValidator(new PipeParameters(attributes)));
}
+ @Test
+ public void testConfigRegionRequiresExplicitAppendResumePolicy() throws Exception {
+ final Map attributes = new HashMap<>();
+ attributes.put(PipeSinkConstant.SINK_LOGICAL_BACKUP_DIR_KEY, "backup");
+
+ assertInvalid(new LogicalBackupSink("config"), attributes);
+
+ attributes.put(
+ PipeSinkConstant.SINK_LOGICAL_BACKUP_RESUME_KEY,
+ PipeSinkConstant.LOGICAL_BACKUP_RESUME_APPEND);
+ new LogicalBackupSink("config")
+ .validate(new PipeParameterValidator(new PipeParameters(attributes)));
+ }
+
private static void assertInvalid(
final LogicalBackupSink sink, final Map attributes) throws Exception {
try {
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
index 11859bb6fe61..d9ff67f160e8 100644
--- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
@@ -38,7 +38,6 @@
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSslSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSyncSink;
-import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.logicalbackup.LogicalBackupSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.opcda.OpcDaSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.opcua.OpcUaSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.websocket.WebSocketSink;
@@ -224,6 +223,7 @@ public String getClassName() {
WEBSOCKET_SINK.getPipePluginName().toUpperCase(),
OPC_UA_SINK.getPipePluginName().toUpperCase(),
OPC_DA_SINK.getPipePluginName().toUpperCase(),
+ LOGICAL_BACKUP_SINK.getPipePluginName().toUpperCase(),
SUBSCRIPTION_SINK.getPipePluginName().toUpperCase(),
IOT_CONSENSUS_V2_ASYNC_SINK.getPipePluginName().toUpperCase(),
PIPE_CONSENSUS_ASYNC_SINK.getPipePluginName().toUpperCase())));
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReader.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReader.java
index 6ac9ca23f67b..b378a16136c1 100644
--- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReader.java
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReader.java
@@ -52,7 +52,10 @@ public class LogicalBackupArchiveReader {
PipeRequestType.TRANSFER_TABLET_RAW_V2.getType(),
PipeRequestType.TRANSFER_PLAN_NODE.getType(),
PipeRequestType.TRANSFER_SCHEMA_SNAPSHOT_PIECE.getType(),
- PipeRequestType.TRANSFER_SCHEMA_SNAPSHOT_SEAL.getType());
+ PipeRequestType.TRANSFER_SCHEMA_SNAPSHOT_SEAL.getType(),
+ PipeRequestType.TRANSFER_CONFIG_PLAN.getType(),
+ PipeRequestType.TRANSFER_CONFIG_SNAPSHOT_PIECE.getType(),
+ PipeRequestType.TRANSFER_CONFIG_SNAPSHOT_SEAL.getType());
public List read(final Path source, final boolean allowIncomplete)
throws IOException {
@@ -84,12 +87,21 @@ public List read(final Path source, final boolean allowIncomplete)
}
streams.sort(
Comparator.comparingInt(
- (BackupStream stream) ->
- "schema".equalsIgnoreCase(stream.getManifest().streamType) ? 0 : 1)
+ (BackupStream stream) -> streamOrder(stream.getManifest().streamType))
.thenComparing((BackupStream stream) -> stream.getManifest().streamId));
return Collections.unmodifiableList(streams);
}
+ private static int streamOrder(final String streamType) {
+ if ("config".equalsIgnoreCase(streamType)) {
+ return 0;
+ }
+ if ("schema".equalsIgnoreCase(streamType)) {
+ return 1;
+ }
+ return 2;
+ }
+
private static void validateArchiveIdentity(
final LogicalBackupManifest expected, final LogicalBackupManifest actual) throws IOException {
validateArchiveIdentityField("backupId", expected.backupId, actual.backupId);
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/payload/thrift/request/PipeTransferHandshakeV2Req.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/payload/thrift/request/PipeTransferHandshakeV2Req.java
index 472679494729..d7e45cad5ae7 100644
--- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/payload/thrift/request/PipeTransferHandshakeV2Req.java
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/payload/thrift/request/PipeTransferHandshakeV2Req.java
@@ -41,6 +41,20 @@ public Map getParams() {
protected abstract PipeRequestType getPlanType();
+ public static TPipeTransferReq toTPipeTransferReq(
+ final PipeRequestType requestType, final Map params) throws IOException {
+ final TPipeTransferReq request = new TPipeTransferReq();
+ request.version = IoTDBSinkRequestVersion.VERSION_1.getVersion();
+ request.type = requestType.getType();
+ try (final PublicBAOS byteArrayOutputStream = new PublicBAOS();
+ final DataOutputStream outputStream = new DataOutputStream(byteArrayOutputStream)) {
+ writeParams(params, outputStream);
+ request.body =
+ ByteBuffer.wrap(byteArrayOutputStream.getBuf(), 0, byteArrayOutputStream.size());
+ }
+ return request;
+ }
+
/////////////////////////////// Thrift ///////////////////////////////
protected final PipeTransferHandshakeV2Req convertToTPipeTransferReq(Map params)
@@ -49,11 +63,7 @@ protected final PipeTransferHandshakeV2Req convertToTPipeTransferReq(Map entry : params.entrySet()) {
- ReadWriteIOUtils.write(entry.getKey(), outputStream);
- ReadWriteIOUtils.write(entry.getValue(), outputStream);
- }
+ writeParams(params, outputStream);
this.body = ByteBuffer.wrap(byteArrayOutputStream.getBuf(), 0, byteArrayOutputStream.size());
}
@@ -61,6 +71,15 @@ protected final PipeTransferHandshakeV2Req convertToTPipeTransferReq(Map params, final DataOutputStream outputStream) throws IOException {
+ ReadWriteIOUtils.write(params.size(), outputStream);
+ for (final Map.Entry entry : params.entrySet()) {
+ ReadWriteIOUtils.write(entry.getKey(), outputStream);
+ ReadWriteIOUtils.write(entry.getValue(), outputStream);
+ }
+ }
+
protected final PipeTransferHandshakeV2Req translateFromTPipeTransferReq(
TPipeTransferReq transferReq) {
Map params = new HashMap<>();
diff --git a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReaderTest.java b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReaderTest.java
index 05f6298e95fa..10db588f2c3c 100644
--- a/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReaderTest.java
+++ b/iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupArchiveReaderTest.java
@@ -35,6 +35,7 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@@ -47,16 +48,18 @@ public class LogicalBackupArchiveReaderTest {
@Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
- public void testReadCompleteArchiveAndOrderSchemaFirst() throws Exception {
+ public void testReadCompleteArchiveAndOrderConfigThenSchemaThenData() throws Exception {
final Path root = temporaryFolder.newFolder().toPath();
writeStream(root.resolve("data"), "data-stream", "data", 4096);
writeStream(root.resolve("schema"), "schema-stream", "schema", 4096);
+ writeStream(root.resolve("config"), "config-stream", "config", 4096);
final List streams = new LogicalBackupArchiveReader().read(root, false);
- Assert.assertEquals(2, streams.size());
- Assert.assertEquals("schema-stream", streams.get(0).getManifest().streamId);
- Assert.assertEquals("data-stream", streams.get(1).getManifest().streamId);
+ Assert.assertEquals(3, streams.size());
+ Assert.assertEquals("config-stream", streams.get(0).getManifest().streamId);
+ Assert.assertEquals("schema-stream", streams.get(1).getManifest().streamId);
+ Assert.assertEquals("data-stream", streams.get(2).getManifest().streamId);
Assert.assertEquals(1, streams.get(0).getEventGroups().size());
Assert.assertEquals(4, streams.get(0).getRecords().size());
Assert.assertEquals(
@@ -175,7 +178,7 @@ public void testNonReplayableRequestTypeIsRejected() throws Exception {
Collections.singletonList(
new TPipeTransferReq()
.setVersion((byte) 1)
- .setType((short) 200)
+ .setType(Short.MAX_VALUE)
.setBody(ByteBuffer.wrap(new byte[] {1}))),
"metadata");
}
@@ -183,6 +186,23 @@ public void testNonReplayableRequestTypeIsRejected() throws Exception {
assertReadFails(directory, false);
}
+ @Test
+ public void testConfigRequestTypesAreReplayable() throws Exception {
+ final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
+ try (final LogicalBackupWriter writer =
+ writer(directory, manifest("config-0", "config"), 4096)) {
+ writer.writeEvent(
+ UUID.randomUUID(),
+ 1,
+ Arrays.asList(request((short) 200), request((short) 201), request((short) 202)),
+ "metadata");
+ }
+
+ final List streams = new LogicalBackupArchiveReader().read(directory, false);
+ Assert.assertEquals(1, streams.size());
+ Assert.assertEquals(3, streams.get(0).getEventGroups().get(0).getRequests().size());
+ }
+
@Test
public void testDuplicateEventGroupIdIsRejected() throws Exception {
final Path directory = temporaryFolder.newFolder().toPath().resolve("backup");
@@ -239,10 +259,15 @@ private static TPipeTransferReq request(final int value) {
}
private static TPipeTransferReq request(final byte[] body) {
- return new TPipeTransferReq()
- .setVersion((byte) 1)
- .setType((short) 10)
- .setBody(ByteBuffer.wrap(body));
+ return request((short) 10, body);
+ }
+
+ private static TPipeTransferReq request(final short type) {
+ return request(type, new byte[] {1});
+ }
+
+ private static TPipeTransferReq request(final short type, final byte[] body) {
+ return new TPipeTransferReq().setVersion((byte) 1).setType(type).setBody(ByteBuffer.wrap(body));
}
private static Path onlySegment(final Path directory) throws IOException {
From 7311c1454f2cd6d46a0b92c35c7566d7a130258d Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Fri, 9 Oct 2026 12:10:42 +0800
Subject: [PATCH 7/8] Fix logical backup builtin plugin import
---
.../commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java | 1 +
1 file changed, 1 insertion(+)
diff --git a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
index d9ff67f160e8..c06b8cf45869 100644
--- a/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
+++ b/iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/agent/plugin/builtin/BuiltinPipePlugin.java
@@ -38,6 +38,7 @@
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSslSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.iotdb.thrift.IoTDBThriftSyncSink;
+import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.logicalbackup.LogicalBackupSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.opcda.OpcDaSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.opcua.OpcUaSink;
import org.apache.iotdb.commons.pipe.agent.plugin.builtin.sink.websocket.WebSocketSink;
From 6355496a6f82ae4bbd25b34f29eabc5996f2ca30 Mon Sep 17 00:00:00 2001
From: Caideyipi <87789683+Caideyipi@users.noreply.github.com>
Date: Mon, 19 Oct 2026 16:40:57 +0800
Subject: [PATCH 8/8] feat: add logical backup dump and restore workflow
---
.../manual/IoTDBPipeLogicalBackupIT.java | 298 +++++
iotdb-client/cli/LOGICAL_BACKUP.md | 138 +++
.../apache/iotdb/cli/i18n/CliMessages.java | 40 +
.../apache/iotdb/cli/i18n/CliMessages.java | 40 +
.../tool/pipe/LogicalBackupDumpOptions.java | 275 +++++
.../tool/pipe/PipeLogicalBackupTool.java | 1059 +++++++++++++----
.../tool/pipe/PipeLogicalBackupToolTest.java | 258 +++-
.../heartbeat/PipeHeartbeatParser.java | 67 +-
.../logicalbackup/LogicalBackupSink.java | 97 +-
.../heartbeat/PipeHeartbeatParserTest.java | 32 +
.../subtask/sink/PipeSinkSubtaskManager.java | 32 +-
.../logicalbackup/LogicalBackupSink.java | 227 ++--
.../dataregion/IoTDBDataRegionSource.java | 11 +
.../PipeRealtimeDataRegionSource.java | 25 +
.../sink/PipeSinkSubtaskManagerTest.java | 21 +
.../logicalbackup/LogicalBackupSinkTest.java | 105 ++
.../PipeRealtimeDataRegionSourceTest.java | 20 +
.../commons/i18n/LogicalBackupMessages.java | 10 +
.../commons/i18n/LogicalBackupMessages.java | 10 +
.../LogicalBackupArchiveReader.java | 147 ++-
.../logicalbackup/LogicalBackupCatalog.java | 277 +++++
.../logicalbackup/LogicalBackupManifest.java | 4 +
.../logicalbackup/LogicalBackupSettings.java | 199 ++++
.../logicalbackup/LogicalBackupWriter.java | 21 +-
.../pipe/source/IoTDBNonDataRegionSource.java | 51 +-
.../LogicalBackupArchiveReaderTest.java | 105 +-
.../LogicalBackupSettingsTest.java | 99 ++
.../LogicalBackupWriterTest.java | 11 +-
.../source/IoTDBNonDataRegionSourceTest.java | 60 +-
29 files changed, 3260 insertions(+), 479 deletions(-)
create mode 100644 integration-test/src/test/java/org/apache/iotdb/pipe/it/dual/treemodel/manual/IoTDBPipeLogicalBackupIT.java
create mode 100644 iotdb-client/cli/LOGICAL_BACKUP.md
create mode 100644 iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/LogicalBackupDumpOptions.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupCatalog.java
create mode 100644 iotdb-core/node-commons/src/main/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupSettings.java
create mode 100644 iotdb-core/node-commons/src/test/java/org/apache/iotdb/commons/pipe/sink/logicalbackup/LogicalBackupSettingsTest.java
diff --git a/integration-test/src/test/java/org/apache/iotdb/pipe/it/dual/treemodel/manual/IoTDBPipeLogicalBackupIT.java b/integration-test/src/test/java/org/apache/iotdb/pipe/it/dual/treemodel/manual/IoTDBPipeLogicalBackupIT.java
new file mode 100644
index 000000000000..4629bcb88750
--- /dev/null
+++ b/integration-test/src/test/java/org/apache/iotdb/pipe/it/dual/treemodel/manual/IoTDBPipeLogicalBackupIT.java
@@ -0,0 +1,298 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.pipe.it.dual.treemodel.manual;
+
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupCatalog;
+import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeRequestType;
+import org.apache.iotdb.db.it.utils.TestUtils;
+import org.apache.iotdb.it.framework.IoTDBTestRunner;
+import org.apache.iotdb.itbase.category.MultiClusterIT2DualTreeManual;
+import org.apache.iotdb.itbase.env.BaseEnv;
+import org.apache.iotdb.tool.pipe.PipeLogicalBackupTool;
+
+import org.awaitility.Awaitility;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(IoTDBTestRunner.class)
+@Category({MultiClusterIT2DualTreeManual.class})
+public class IoTDBPipeLogicalBackupIT extends AbstractPipeDualTreeModelManualIT {
+
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Override
+ protected void setupConfig() {
+ super.setupConfig();
+ for (final BaseEnv env : Arrays.asList(senderEnv, receiverEnv)) {
+ env.getConfig().getConfigNodeJVMConfig().setInitHeapSize(128).setMaxHeapSize(256);
+ env.getConfig().getDataNodeJVMConfig().setInitHeapSize(256).setMaxHeapSize(768);
+ }
+ }
+
+ @Test
+ public void testBoundedContinuousTableCapture() throws Exception {
+ try (final Connection connection = senderEnv.getTableConnection();
+ final Statement statement = connection.createStatement()) {
+ statement.execute("create database backup_db");
+ statement.execute("use backup_db");
+ statement.execute("create table measurements(device STRING TAG, reading INT32 FIELD)");
+ statement.execute("create table other_table(device STRING TAG, reading INT32 FIELD)");
+ statement.execute("insert into measurements(time,device,reading) values(1,'d1',10)");
+ statement.execute("insert into other_table(time,device,reading) values(1,'d1',100)");
+ statement.execute("flush");
+ }
+ final String pipeName = "logical_backup_continuous_table";
+ final CompletableFuture writes =
+ CompletableFuture.runAsync(
+ () -> {
+ try {
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .until(
+ () -> {
+ try (final Connection connection = senderEnv.getConnection();
+ final Statement statement = connection.createStatement();
+ final ResultSet result =
+ statement.executeQuery("show pipe " + pipeName)) {
+ return result.next();
+ }
+ });
+ try (final Connection connection = senderEnv.getTableConnection();
+ final Statement statement = connection.createStatement()) {
+ statement.execute("use backup_db");
+ statement.execute(
+ "insert into measurements(time,device,reading) values(2,'d1',20)");
+ statement.execute(
+ "insert into measurements(time,device,reading) values(4,'d1',40)");
+ statement.execute(
+ "insert into other_table(time,device,reading) values(2,'d1',200)");
+ statement.execute("delete from measurements where time <= 1");
+ }
+ } catch (final Exception e) {
+ throw new CompletionException(e);
+ }
+ });
+ final Path parent = temporaryFolder.getRoot().toPath().toAbsolutePath();
+ runConnected(
+ senderEnv,
+ "dump",
+ "--backup-dir",
+ parent.toString(),
+ "--backup-id",
+ "bounded",
+ "--pipe-name",
+ pipeName,
+ "--scope",
+ "schema,data",
+ "--model",
+ "table",
+ "--include",
+ "backup_db/measurements",
+ "--start-time",
+ "0",
+ "--end-time",
+ "3",
+ "--mode",
+ "continuous",
+ "--until",
+ Instant.now().plusSeconds(20).toString(),
+ "--timeout-seconds",
+ "180",
+ "--poll-interval-seconds",
+ "1");
+ writes.get(30, TimeUnit.SECONDS);
+ final Path backup = parent.resolve("bounded");
+ final LogicalBackupCatalog catalog = LogicalBackupCatalog.load(backup.resolve("manifest.json"));
+ Assert.assertEquals("SEALED", catalog.status);
+ Assert.assertEquals("continuous", catalog.mode);
+ assertLogicalRequests(backup);
+ runConnected(receiverEnv, "restore", "--input", backup.toString());
+ try (final Connection connection = receiverEnv.getTableConnection();
+ final Statement statement = connection.createStatement()) {
+ statement.execute("use backup_db");
+ try (final ResultSet result = statement.executeQuery("select * from measurements")) {
+ Assert.assertTrue(result.next());
+ Assert.assertEquals(2L, result.getLong("time"));
+ Assert.assertEquals("d1", result.getString("device"));
+ Assert.assertEquals(20, result.getInt("reading"));
+ Assert.assertFalse(result.next());
+ }
+ try (final ResultSet result = statement.executeQuery("show tables")) {
+ while (result.next()) {
+ Assert.assertNotEquals("other_table", result.getString(1));
+ }
+ }
+ }
+ }
+
+ @Test
+ public void testDumpPackPreflightRestoreWithScopeAndTimeFilters() throws Exception {
+ TestUtils.executeNonQueries(
+ senderEnv,
+ Arrays.asList(
+ "create database root.backup",
+ "create aligned timeseries root.backup.d(s1 INT32, s2 BOOLEAN)",
+ "insert into root.backup.d(time,s1,s2) aligned values(1,10,true)",
+ "insert into root.backup.d(time,s2) aligned values(2,true)",
+ "insert into root.backup.d(time,s1,s2) aligned values(3,30,false)",
+ "insert into root.backup.d(time,s1,s2) aligned values(4,40,true)",
+ "create database root.unselected",
+ "create timeseries root.unselected.d.s with datatype=INT32,encoding=PLAIN",
+ "insert into root.unselected.d(time,s) values(2,100)",
+ "create user logical_backup_excluded_user 'test_password'",
+ "flush"),
+ null);
+ final Path parent = temporaryFolder.getRoot().toPath().toAbsolutePath();
+ runConnected(
+ senderEnv,
+ "dump",
+ "--backup-dir",
+ parent.toString(),
+ "--backup-id",
+ "snapshot",
+ "--scope",
+ "config,schema,data",
+ "--model",
+ "tree",
+ "--include",
+ "root.backup.**",
+ "--start-time",
+ "2",
+ "--end-time",
+ "3",
+ "--timeout-seconds",
+ "180",
+ "--poll-interval-seconds",
+ "1");
+ final Path backup = parent.resolve("snapshot");
+ final LogicalBackupCatalog catalog = LogicalBackupCatalog.load(backup.resolve("manifest.json"));
+ Assert.assertEquals("SEALED", catalog.status);
+ Assert.assertFalse(catalog.includeAuth);
+ Assert.assertFalse(catalog.streams.isEmpty());
+ assertLogicalRequests(backup);
+ final Path zip = parent.resolve("snapshot.zip");
+ Assert.assertEquals(
+ 0,
+ PipeLogicalBackupTool.run(new String[] {"verify", "--input", backup.toString(), "--json"}));
+ Assert.assertEquals(
+ 0,
+ PipeLogicalBackupTool.run(
+ new String[] {"pack", "--input", backup.toString(), "--output", zip.toString()}));
+ Assert.assertEquals(
+ 0,
+ PipeLogicalBackupTool.run(
+ new String[] {"restore", "--input", zip.toString(), "--dry-run"}));
+ runConnected(receiverEnv, "restore", "--input", zip.toString(), "--dry-run");
+ try (final Connection connection = receiverEnv.getConnection();
+ final Statement statement = connection.createStatement();
+ final ResultSet result = statement.executeQuery("show timeseries root.backup.**")) {
+ Assert.assertFalse(result.next());
+ }
+ final Path checkpoint = parent.resolve("restore-checkpoint.json");
+ runConnected(
+ receiverEnv, "restore", "--input", zip.toString(), "--checkpoint", checkpoint.toString());
+ runConnected(
+ receiverEnv,
+ "restore",
+ "--input",
+ zip.toString(),
+ "--checkpoint",
+ checkpoint.toString(),
+ "--resume");
+ TestUtils.assertDataEventuallyOnEnv(
+ receiverEnv,
+ "select * from root.backup.**",
+ "Time,root.backup.d.s1,root.backup.d.s2,",
+ new HashSet<>(Arrays.asList("2,null,true,", "3,30,false,")));
+ try (final Connection connection = receiverEnv.getConnection();
+ final Statement statement = connection.createStatement()) {
+ try (final ResultSet result = statement.executeQuery("show timeseries root.unselected.**")) {
+ Assert.assertFalse(result.next());
+ }
+ try (final ResultSet result = statement.executeQuery("list user")) {
+ while (result.next()) {
+ for (int column = 1; column <= result.getMetaData().getColumnCount(); column++) {
+ Assert.assertNotEquals("logical_backup_excluded_user", result.getString(column));
+ }
+ }
+ }
+ }
+ }
+
+ private static void assertLogicalRequests(final Path backup) throws Exception {
+ final Set logicalRequests =
+ Set.of(
+ PipeRequestType.TRANSFER_CONFIG_PLAN.getType(),
+ PipeRequestType.TRANSFER_PLAN_NODE.getType(),
+ PipeRequestType.TRANSFER_TABLET_RAW_V2.getType(),
+ PipeRequestType.TRANSFER_TABLET_INSERT_NODE_V2.getType());
+ new LogicalBackupArchiveReader()
+ .read(backup, false).stream()
+ .flatMap(stream -> stream.getEventGroups().stream())
+ .flatMap(group -> group.getRequests().stream())
+ .forEach(
+ record -> Assert.assertTrue(logicalRequests.contains(record.getRequestType())));
+ }
+
+ private static void runConnected(final BaseEnv env, final String command, final String... options)
+ throws Exception {
+ final List args =
+ new ArrayList<>(
+ Arrays.asList(
+ command,
+ "--host",
+ env.getIP(),
+ "--port",
+ env.getPort(),
+ "--user",
+ "root",
+ "--password-stdin"));
+ args.addAll(Arrays.asList(options));
+ final InputStream previous = System.in;
+ try {
+ System.setIn(new ByteArrayInputStream("root\n".getBytes(StandardCharsets.UTF_8)));
+ Assert.assertEquals(0, PipeLogicalBackupTool.run(args.toArray(new String[0])));
+ } finally {
+ System.setIn(previous);
+ }
+ }
+}
diff --git a/iotdb-client/cli/LOGICAL_BACKUP.md b/iotdb-client/cli/LOGICAL_BACKUP.md
new file mode 100644
index 000000000000..2fa605314e42
--- /dev/null
+++ b/iotdb-client/cli/LOGICAL_BACKUP.md
@@ -0,0 +1,138 @@
+
+
+# Logical backup
+
+Use `tools/logical-backup.sh` on Linux/macOS or
+`tools/windows/logical-backup.bat` on Windows. `--help` lists the supported options.
+Commands exit with 0 on success and 1 on failure. Errors go to stderr; `--json`
+provides a machine-readable result on stdout.
+
+## Capture, check, transport, restore
+
+```sh
+tools/logical-backup.sh dump --host source-host --port 6667 --user root \
+ --password-stdin --backup-dir /mnt/shared-backups --backup-id migration_001 \
+ --scope config,schema,data --model tree,table --mode snapshot
+
+tools/logical-backup.sh inspect --input /mnt/shared-backups/migration_001 --json
+tools/logical-backup.sh verify --input /mnt/shared-backups/migration_001 --json
+tools/logical-backup.sh pack --input /mnt/shared-backups/migration_001 \
+ --output /archives/migration_001.zip
+
+# Local verification; no target connection is opened.
+tools/logical-backup.sh restore --input /archives/migration_001.zip --dry-run
+
+# Target version, precision, authentication and protocol capability checks.
+tools/logical-backup.sh restore --input /archives/migration_001.zip \
+ --host target-host --port 6667 --user root --password-stdin --dry-run
+
+tools/logical-backup.sh restore --input /archives/migration_001.zip \
+ --host target-host --port 6667 --user root --password-stdin \
+ --checkpoint /archives/migration_001.restore.json
+
+# After an interrupted restore, retain all connection and input options and add --resume.
+tools/logical-backup.sh restore --input /archives/migration_001.zip \
+ --host target-host --port 6667 --user root --password-stdin \
+ --checkpoint /archives/migration_001.restore.json --resume
+```
+
+Passwords are read from stdin or `--password-env NAME`. Plaintext password
+arguments are rejected. Manifests and checkpoints contain no connection secrets.
+
+`dump` generates a directory, and `pack` only packages an existing directory.
+`pack` rejects connection options. Output and checkpoint files must be outside
+the input backup, and existing output archives are never overwritten.
+
+All source ConfigNodes, DataNodes, and the CLI must access the same backup
+directory at the specified path. This is a shared filesystem workflow, not a
+remote download protocol. Do not modify a directory while it is being checked,
+packed or restored.
+
+## Scope and boundaries
+
+`--scope` is required and selects `config`, `schema`, `data`, or a comma-separated
+combination. Schema capture includes its ConfigRegion definitions, such as
+databases and tables. `--model` defaults to `tree,table`.
+
+Authorization objects are excluded by default. Capturing them requires
+`--scope config` (possibly combined with other scopes) and `--include-auth true`.
+Restoring them also requires `--include-auth true`; inspect/dry-run reports this
+selection. Legacy config archives are conservatively treated as containing
+authorization objects.
+
+Object filters require a single model. Tree data accepts source path inclusion
+and exclusion lists; when capturing config/schema, the source supports a single
+prefix or full path without exclusions. Table filters use
+`database-regex/table-regex`; exclusions must match either whole databases or
+whole table-name patterns. Unsupported combinations fail before task creation.
+Data `--start-time` and `--end-time` are inclusive and use source timestamp
+precision. These filters do not remove the schema needed to describe selected
+objects.
+
+`snapshot` captures finite historical streams. `continuous` requires an ISO-8601
+`--until` boundary. Each source stops accepting realtime work at its capture
+boundary, drains accepted work, and acknowledges completion before task removal.
+The boundary uses source clocks; streams do not represent a global transaction.
+`--stop-sequence` is reserved and rejected because this server does not expose a
+stable global source sequence. `sourceHighWatermark` records each stream's durable
+archive sequence; it is not a cluster-wide source log position.
+
+The root `manifest.json` is the portable entry point. It declares scope,
+models, filters, capture settings, streams, relative file paths, full-file SHA-256
+digests, counts, status and capture/seal times. Stream manifests describe the
+versioned `.pwal` frames, CRCs, segment digests and committed event groups. Legacy
+stream-only directories remain readable; packing them adds a root manifest
+without changing the input.
+
+Config and Schema snapshots are decoded into logical plans before capture;
+data files are decoded into logical tablet requests. Each Region has its own
+ordered stream and writer. Pipe parameter validation creates no backup files.
+
+## Interrupted work and conflicts
+
+`dump --resume fail-if-exists` is the default. `new` chooses a new backup identity
+if the requested directory exists. `append` requires the same identity, scope
+and writing settings. A timed-out command reports the backup, directory and
+Pipe identity; query `SHOW PIPE` or rerun the same dump with `--resume append`.
+An already sealed matching backup is returned after verification.
+
+Writer sizes, fsync policy and batch/period combinations are validated before
+writing. Unknown events fail by default; `--on-unsupported skip` records a gap
+and seals the result as `INCOMPLETE`. Inspect, verify, pack and restore reject
+incomplete backups unless `--allow-incomplete` is explicit. That flag never
+bypasses checksum errors, truncated segments, missing files or invalid groups.
+
+Restore validates locally before connecting, then replays config, schema and
+data, sorting equal-type streams by stream ID. Dry-run sends no replay requests.
+Target preflight checks version, precision, authentication and protocol
+handshakes; object conflicts and object-level permissions are checked during
+replay. Only `--conflict fail` is supported. Target errors stop replay and retain
+the checkpoint; no schema is silently deleted, overwritten or skipped.
+
+Checkpoints bind the archive/content digest, backup format and identity, target
+host/port/user, completed groups and the next request within a group. A request
+that may have succeeded immediately before an interrupted checkpoint update can
+be retried; only that uncertain request accepts an idempotent-conflict response.
+There is no transaction across requests or streams. Completed checkpoints remain
+available for audit and repeat invocation.
+
+`export` and `import` are deprecated compatibility aliases. Offline `export` is
+`pack`; connected `export` performs `dump` then `pack`; `import` is `restore`.
+Both aliases print a migration notice on stderr.
diff --git a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
index 1316415682f5..9f49fc238e6e 100644
--- a/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/en/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -169,4 +169,44 @@ private CliMessages() {}
EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_HAS_INVALID_IDENTIFIER_ARG_BB015204 =
"Logical backup option --%s has invalid identifier: %s";
+ public static final String LOG_EXPORT_IS_DEPRECATED_USE_DUMP_TO_GENERATE_A_BACKUP_AND_PACK_TO_CREATE_A_ZIP_47709D2D =
+ "export is deprecated; use dump to generate a backup and pack to create a ZIP.";
+ public static final String LOG_IMPORT_IS_DEPRECATED_USE_RESTORE_2F1AE2CE =
+ "import is deprecated; use restore.";
+ public static final String LOG_LOGICAL_BACKUP_ARG_1E5C23CE =
+ "Logical backup: %s";
+ public static final String LOG_LOGICAL_BACKUP_PACKED_FROM_ARG_TO_ARG_CB9D5304 =
+ "Logical backup packed from %s to %s";
+public static final String EXCEPTION_STOP_SEQUENCE_REQUIRES_A_STABLE_SOURCE_SEQUENCE_CAPABILITY_USE_UNTIL_WITH_THIS_SERVER_VERSION_8323B09E =
+ "--stop-sequence requires a stable source sequence capability; use --until with this server version.";
+ public static final String EXCEPTION_OBJECT_FILTERS_REQUIRE_MODEL_TREE_OR_MODEL_TABLE_SPLIT_MIXED_MODEL_FILTERED_DUMPS_INTO_SEPARATE_BACKUPS_0488699E =
+ "Object filters require --model tree or --model table; split mixed-model filtered dumps into separate backups.";
+ public static final String EXCEPTION_TABLE_FILTERS_USE_DATABASE_REGEX_TABLE_REGEX_EXCLUSIONS_MUST_USE_FOR_EITHER_DATABASE_OR_TABLE_42F0FA23 =
+ "Table filters use database-regex/table-regex; exclusions must use .* for either database or table.";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_DUMP_OPTION_ARG_894E11E0 =
+ "Invalid logical backup dump option: --%s";
+ public static final String EXCEPTION_INVALID_OR_CONFLICTING_LOGICAL_BACKUP_ARGUMENTS_76233F81 =
+ "Invalid or conflicting logical backup arguments.";
+ public static final String EXCEPTION_ONLY_CONFLICT_FAIL_IS_SUPPORTED_RESTORE_NEVER_IMPLICITLY_OVERWRITES_OR_DELETES_TARGET_OBJECTS_70C9523E =
+ "Only --conflict fail is supported; restore never implicitly overwrites or deletes target objects.";
+ public static final String EXCEPTION_THE_EXISTING_BACKUP_TASK_DOES_NOT_MATCH_THIS_BACKUP_IDENTITY_OR_CAPTURE_OPTIONS_50E6388F =
+ "The existing backup task does not match this backup identity or capture options.";
+ public static final String EXCEPTION_ANOTHER_DUMP_PROCESS_IS_MANAGING_THIS_BACKUP_FE8EE828 =
+ "Another dump process is managing this backup.";
+ public static final String EXCEPTION_BACKUP_ARG_TASK_ARG_DIRECTORY_ARG_ARG_THE_TASK_IS_RETAINED_QUERY_SHOW_PIPE_OR_REPEAT_DUMP_WITH_THE_SAME_OPTIONS_AND_RESUME_APPEND_6F8C1E87 =
+ "Backup %s, task %s, directory %s: %s. The task is retained; query SHOW PIPE or repeat dump with the same options and --resume append.";
+ public static final String EXCEPTION_THE_BACKUP_TASK_HAS_NOT_PRODUCED_SEALED_STREAMS_YET_RESUME_THE_SAME_TASK_TO_FINISH_SEALING_87C9B599 =
+ "The backup task has not produced sealed streams yet; resume the same task to finish sealing.";
+ public static final String EXCEPTION_AN_EXISTING_CHECKPOINT_REQUIRES_RESUME_RESUME_REQUIRES_AN_EXISTING_CHECKPOINT_FB6D0B7D =
+ "An existing checkpoint requires --resume; --resume requires an existing checkpoint.";
+ public static final String EXCEPTION_THE_TARGET_TIMESTAMP_PRECISION_DOES_NOT_MATCH_THE_BACKUP_70B225CB =
+ "The target timestamp precision does not match the backup.";
+ public static final String EXCEPTION_THE_TARGET_VERSION_OR_DATA_MODEL_IS_INCOMPATIBLE_WITH_THE_BACKUP_220EB40C =
+ "The target version or data model is incompatible with the backup.";
+ public static final String EXCEPTION_THE_CHECKPOINT_MUST_BE_OUTSIDE_THE_INPUT_BACKUP_15B70F59 =
+ "The checkpoint must be outside the input backup.";
+ public static final String EXCEPTION_RESTORING_AUTHORIZATION_OBJECTS_REQUIRES_INCLUDE_AUTH_TRUE_INSPECT_THE_BACKUP_OR_USE_DRY_RUN_FIRST_83DDCD79 =
+ "Restoring authorization objects requires --include-auth true; inspect the backup or use --dry-run first.";
+ public static final String LOG_LOGICAL_BACKUP_DUMP_VERIFY_INSPECT_PACK_RESTORE_OPTIONS_DUMP_CONNECT_TO_A_SOURCE_AND_SEAL_A_BACKUP_DIRECTORY_DOES_NOT_CREATE_A_ZIP_INSPECT_SHOW_BACKUP_IDENTITY_SCOPE_STREAMS_SEGMENTS_COUNTS_AND_STATUS_VERIFY_CHECK_STRUCTURE_CHECKSUMS_AND_SEMANTICS_WITHOUT_CHANGING_THE_INPUT_PACK_VERIFY_AND_PACKAGE_AN_EXISTING_DIRECTORY_AS_ZIP_ACCEPTS_NO_CONNECTION_OPTIONS_RESTORE_PREFLIGHT_AND_REPLAY_A_DIRECTORY_ROOT_MANIFEST_JSON_OR_ZIP_INSPECT_VERIFY_INPUT_PATH_JSON_ALLOW_INCOMPLETE_PACK_INPUT_DIR_OUTPUT_ZIP_JSON_ALLOW_INCOMPLETE_DUMP_HOST_HOST_PORT_PORT_USER_ROOT_PASSWORD_STDIN_PASSWORD_ENV_NAME_BACKUP_DIR_DIR_BACKUP_ID_ID_SCOPE_CONFIG_SCHEMA_DATA_MODEL_TREE_TABLE_INCLUDE_PATTERN_EXCLUDE_PATTERN_START_TIME_TIME_END_TIME_TIME_INCLUDE_AUTH_TRUE_FALSE_MODE_SNAPSHOT_CONTINUOUS_UNTIL_ISO_8601_RESUME_FAIL_IF_EXISTS_APPEND_NEW_PIPE_NAME_NAME_SEGMENT_SIZE_BYTES_268435456_MAX_RECORD_BYTES_67108864_FSYNC_POLICY_ALWAYS_BATCH_PERIODIC_NONE_FSYNC_BATCH_OPERATIONS_N_FSYNC_PERIOD_MS_N_ON_UNSUPPORTED_FAIL_SKIP_INCLUDE_HEARTBEAT_TRUE_FALSE_TIMEOUT_SECONDS_3600_POLL_INTERVAL_SECONDS_2_JSON_ALLOW_INCOMPLETE_CONTINUOUS_REQUIRES_UNTIL_STOP_SEQUENCE_IS_RESERVED_AND_CURRENTLY_REJECTED_OBJECT_FILTERS_REQUIRE_ONE_MODEL_TREE_USES_PATH_LISTS_TABLE_USES_DATABASE_REGEX_TABLE_REGEX_TREE_CONFIG_SCHEMA_CAPTURE_ACCEPTS_A_SINGLE_PREFIX_FULL_PATH_WITHOUT_EXCLUSIONS_TABLE_EXCLUSIONS_MUST_SELECT_WHOLE_DATABASES_OR_WHOLE_TABLE_NAME_PATTERNS_RESTORE_INPUT_PATH_HOST_HOST_PORT_PORT_USER_USER_PASSWORD_STDIN_PASSWORD_ENV_NAME_DRY_RUN_CHECKPOINT_FILE_RESUME_CONFLICT_FAIL_INCLUDE_AUTH_TRUE_FALSE_JSON_ALLOW_INCOMPLETE_RESTORING_AUTHORIZATION_OBJECTS_REQUIRES_INCLUDE_AUTH_TRUE_LEGACY_CONFIG_IS_CONSERVATIVE_A_TARGET_CONNECTION_IS_REQUIRED_FOR_REPLAY_LOCAL_DRY_RUN_NEEDS_NO_CONNECTION_TARGET_PREFLIGHT_CHECKS_VERSION_PRECISION_AUTHENTICATION_AND_HANDSHAKE_SUPPORT_OBJECT_CONFLICTS_AND_PER_OBJECT_PRIVILEGES_ARE_CHECKED_DURING_REPLAY_DEPRECATED_ALIASES_EXPORT_INPUT_DIR_OUTPUT_ZIP_PACK_EXPORT_WITH_SOURCE_CONNECTION_OPTIONS_DUMP_THEN_PACK_IMPORT_RESTORE_EXIT_CODE_0_SUCCESS_1_FAILURE_ERRORS_AND_DEPRECATION_NOTICES_GO_TO_STDERR_B1983BE8 =
+ "logical-backup [options]\n dump Connect to a source and seal a backup directory; does not create a ZIP.\n inspect Show backup identity, scope, streams, segments, counts and status.\n verify Check structure, checksums and semantics without changing the input.\n pack Verify and package an existing directory as ZIP; accepts no connection options.\n restore Preflight and replay a directory, root manifest.json or ZIP.\n\ninspect/verify: --input PATH [--json] [--allow-incomplete]\npack: --input DIR --output ZIP [--json] [--allow-incomplete]\ndump: --host HOST --port PORT [--user root] (--password-stdin|--password-env NAME)\n --backup-dir DIR --backup-id ID --scope config,schema,data [--model tree,table]\n [--include PATTERN] [--exclude PATTERN] [--start-time TIME] [--end-time TIME]\n [--include-auth true|false] [--mode snapshot|continuous] [--until ISO-8601]\n [--resume fail-if-exists|append|new] [--pipe-name NAME]\n [--segment-size-bytes 268435456] [--max-record-bytes 67108864]\n [--fsync-policy always|batch|periodic|none] [--fsync-batch-operations N|--fsync-period-ms N]\n [--on-unsupported fail|skip] [--include-heartbeat true|false]\n [--timeout-seconds 3600] [--poll-interval-seconds 2] [--json] [--allow-incomplete]\n continuous requires --until; --stop-sequence is reserved and currently rejected.\n Object filters require one model: Tree uses path lists; Table uses database-regex/table-regex.\n Tree config/schema capture accepts a single prefix/full path without exclusions.\n Table exclusions must select whole databases or whole table-name patterns.\nrestore: --input PATH [--host HOST --port PORT --user USER\n (--password-stdin|--password-env NAME)] [--dry-run] [--checkpoint FILE] [--resume]\n [--conflict fail] [--include-auth true|false] [--json] [--allow-incomplete]\n Restoring authorization objects requires --include-auth true (legacy config is conservative).\n A target connection is required for replay; local dry-run needs no connection.\n Target preflight checks version, precision, authentication and handshake support;\n object conflicts and per-object privileges are checked during replay.\n\nDeprecated aliases: export --input DIR --output ZIP = pack;\n export with source connection options = dump then pack; import = restore.\nExit code: 0 success, 1 failure. Errors and deprecation notices go to stderr.";
}
diff --git a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
index c26071d0bf5f..b8733d990479 100644
--- a/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
+++ b/iotdb-client/cli/src/main/i18n/zh/org/apache/iotdb/cli/i18n/CliMessages.java
@@ -165,4 +165,44 @@ private CliMessages() {}
public static final String
EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_HAS_INVALID_IDENTIFIER_ARG_BB015204 =
"逻辑备份选项 --%s 的标识符无效:%s";
+ public static final String LOG_EXPORT_IS_DEPRECATED_USE_DUMP_TO_GENERATE_A_BACKUP_AND_PACK_TO_CREATE_A_ZIP_47709D2D =
+ "export 已弃用;请使用 dump 生成备份,使用 pack 创建 ZIP。";
+ public static final String LOG_IMPORT_IS_DEPRECATED_USE_RESTORE_2F1AE2CE =
+ "import 已弃用;请使用 restore。";
+ public static final String LOG_LOGICAL_BACKUP_ARG_1E5C23CE =
+ "逻辑备份:%s";
+ public static final String LOG_LOGICAL_BACKUP_PACKED_FROM_ARG_TO_ARG_CB9D5304 =
+ "已将逻辑备份从 %s 打包至 %s";
+public static final String EXCEPTION_STOP_SEQUENCE_REQUIRES_A_STABLE_SOURCE_SEQUENCE_CAPABILITY_USE_UNTIL_WITH_THIS_SERVER_VERSION_8323B09E =
+ "--stop-sequence 需要源端提供稳定序号能力;当前服务端版本请使用 --until。";
+ public static final String EXCEPTION_OBJECT_FILTERS_REQUIRE_MODEL_TREE_OR_MODEL_TABLE_SPLIT_MIXED_MODEL_FILTERED_DUMPS_INTO_SEPARATE_BACKUPS_0488699E =
+ "对象过滤需要 --model tree 或 --model table;请将混合模型过滤拆为独立备份。";
+ public static final String EXCEPTION_TABLE_FILTERS_USE_DATABASE_REGEX_TABLE_REGEX_EXCLUSIONS_MUST_USE_FOR_EITHER_DATABASE_OR_TABLE_42F0FA23 =
+ "Table 过滤使用数据库正则/表名正则;排除过滤中数据库或表名必须有一项为 .*。";
+ public static final String EXCEPTION_INVALID_LOGICAL_BACKUP_DUMP_OPTION_ARG_894E11E0 =
+ "逻辑备份 dump 选项无效:--%s";
+ public static final String EXCEPTION_INVALID_OR_CONFLICTING_LOGICAL_BACKUP_ARGUMENTS_76233F81 =
+ "逻辑备份参数无效或相互冲突。";
+ public static final String EXCEPTION_ONLY_CONFLICT_FAIL_IS_SUPPORTED_RESTORE_NEVER_IMPLICITLY_OVERWRITES_OR_DELETES_TARGET_OBJECTS_70C9523E =
+ "当前仅支持 --conflict fail;restore 不会隐式覆盖或删除目标对象。";
+ public static final String EXCEPTION_THE_EXISTING_BACKUP_TASK_DOES_NOT_MATCH_THIS_BACKUP_IDENTITY_OR_CAPTURE_OPTIONS_50E6388F =
+ "已有备份任务与本次备份身份或捕获选项不一致。";
+ public static final String EXCEPTION_ANOTHER_DUMP_PROCESS_IS_MANAGING_THIS_BACKUP_FE8EE828 =
+ "其他 dump 进程正在管理此备份。";
+ public static final String EXCEPTION_BACKUP_ARG_TASK_ARG_DIRECTORY_ARG_ARG_THE_TASK_IS_RETAINED_QUERY_SHOW_PIPE_OR_REPEAT_DUMP_WITH_THE_SAME_OPTIONS_AND_RESUME_APPEND_6F8C1E87 =
+ "备份 %s,任务 %s,目录 %s:%s。任务已保留;可使用 SHOW PIPE 查询,或使用相同选项和 --resume append 继续 dump。";
+ public static final String EXCEPTION_THE_BACKUP_TASK_HAS_NOT_PRODUCED_SEALED_STREAMS_YET_RESUME_THE_SAME_TASK_TO_FINISH_SEALING_87C9B599 =
+ "备份任务尚未产出封存的流;请继续原任务以完成封存。";
+ public static final String EXCEPTION_AN_EXISTING_CHECKPOINT_REQUIRES_RESUME_RESUME_REQUIRES_AN_EXISTING_CHECKPOINT_FB6D0B7D =
+ "使用已有 checkpoint 必须指定 --resume;--resume 要求 checkpoint 已存在。";
+ public static final String EXCEPTION_THE_TARGET_TIMESTAMP_PRECISION_DOES_NOT_MATCH_THE_BACKUP_70B225CB =
+ "目标时间精度与备份不一致。";
+ public static final String EXCEPTION_THE_TARGET_VERSION_OR_DATA_MODEL_IS_INCOMPATIBLE_WITH_THE_BACKUP_220EB40C =
+ "目标版本或数据模型与备份不兼容。";
+ public static final String EXCEPTION_THE_CHECKPOINT_MUST_BE_OUTSIDE_THE_INPUT_BACKUP_15B70F59 =
+ "checkpoint 必须位于输入备份之外。";
+ public static final String EXCEPTION_RESTORING_AUTHORIZATION_OBJECTS_REQUIRES_INCLUDE_AUTH_TRUE_INSPECT_THE_BACKUP_OR_USE_DRY_RUN_FIRST_83DDCD79 =
+ "恢复权限对象必须显式指定 --include-auth true;请先 inspect 备份或执行 --dry-run。";
+ public static final String LOG_LOGICAL_BACKUP_DUMP_VERIFY_INSPECT_PACK_RESTORE_OPTIONS_DUMP_CONNECT_TO_A_SOURCE_AND_SEAL_A_BACKUP_DIRECTORY_DOES_NOT_CREATE_A_ZIP_INSPECT_SHOW_BACKUP_IDENTITY_SCOPE_STREAMS_SEGMENTS_COUNTS_AND_STATUS_VERIFY_CHECK_STRUCTURE_CHECKSUMS_AND_SEMANTICS_WITHOUT_CHANGING_THE_INPUT_PACK_VERIFY_AND_PACKAGE_AN_EXISTING_DIRECTORY_AS_ZIP_ACCEPTS_NO_CONNECTION_OPTIONS_RESTORE_PREFLIGHT_AND_REPLAY_A_DIRECTORY_ROOT_MANIFEST_JSON_OR_ZIP_INSPECT_VERIFY_INPUT_PATH_JSON_ALLOW_INCOMPLETE_PACK_INPUT_DIR_OUTPUT_ZIP_JSON_ALLOW_INCOMPLETE_DUMP_HOST_HOST_PORT_PORT_USER_ROOT_PASSWORD_STDIN_PASSWORD_ENV_NAME_BACKUP_DIR_DIR_BACKUP_ID_ID_SCOPE_CONFIG_SCHEMA_DATA_MODEL_TREE_TABLE_INCLUDE_PATTERN_EXCLUDE_PATTERN_START_TIME_TIME_END_TIME_TIME_INCLUDE_AUTH_TRUE_FALSE_MODE_SNAPSHOT_CONTINUOUS_UNTIL_ISO_8601_RESUME_FAIL_IF_EXISTS_APPEND_NEW_PIPE_NAME_NAME_SEGMENT_SIZE_BYTES_268435456_MAX_RECORD_BYTES_67108864_FSYNC_POLICY_ALWAYS_BATCH_PERIODIC_NONE_FSYNC_BATCH_OPERATIONS_N_FSYNC_PERIOD_MS_N_ON_UNSUPPORTED_FAIL_SKIP_INCLUDE_HEARTBEAT_TRUE_FALSE_TIMEOUT_SECONDS_3600_POLL_INTERVAL_SECONDS_2_JSON_ALLOW_INCOMPLETE_CONTINUOUS_REQUIRES_UNTIL_STOP_SEQUENCE_IS_RESERVED_AND_CURRENTLY_REJECTED_OBJECT_FILTERS_REQUIRE_ONE_MODEL_TREE_USES_PATH_LISTS_TABLE_USES_DATABASE_REGEX_TABLE_REGEX_TREE_CONFIG_SCHEMA_CAPTURE_ACCEPTS_A_SINGLE_PREFIX_FULL_PATH_WITHOUT_EXCLUSIONS_TABLE_EXCLUSIONS_MUST_SELECT_WHOLE_DATABASES_OR_WHOLE_TABLE_NAME_PATTERNS_RESTORE_INPUT_PATH_HOST_HOST_PORT_PORT_USER_USER_PASSWORD_STDIN_PASSWORD_ENV_NAME_DRY_RUN_CHECKPOINT_FILE_RESUME_CONFLICT_FAIL_INCLUDE_AUTH_TRUE_FALSE_JSON_ALLOW_INCOMPLETE_RESTORING_AUTHORIZATION_OBJECTS_REQUIRES_INCLUDE_AUTH_TRUE_LEGACY_CONFIG_IS_CONSERVATIVE_A_TARGET_CONNECTION_IS_REQUIRED_FOR_REPLAY_LOCAL_DRY_RUN_NEEDS_NO_CONNECTION_TARGET_PREFLIGHT_CHECKS_VERSION_PRECISION_AUTHENTICATION_AND_HANDSHAKE_SUPPORT_OBJECT_CONFLICTS_AND_PER_OBJECT_PRIVILEGES_ARE_CHECKED_DURING_REPLAY_DEPRECATED_ALIASES_EXPORT_INPUT_DIR_OUTPUT_ZIP_PACK_EXPORT_WITH_SOURCE_CONNECTION_OPTIONS_DUMP_THEN_PACK_IMPORT_RESTORE_EXIT_CODE_0_SUCCESS_1_FAILURE_ERRORS_AND_DEPRECATION_NOTICES_GO_TO_STDERR_B1983BE8 =
+ "logical-backup [选项]\n dump 连接源端并封存备份目录,不生成 ZIP。\n inspect 查看备份身份、范围、流、segment、计数和状态。\n verify 检查结构、校验和与语义,不修改输入。\n pack 校验已有目录并打包为 ZIP,不接受数据库连接参数。\n restore 预检并回放备份目录、根 manifest.json 或 ZIP。\n\ninspect/verify: --input PATH [--json] [--allow-incomplete]\npack: --input DIR --output ZIP [--json] [--allow-incomplete]\ndump: --host HOST --port PORT [--user root] (--password-stdin|--password-env NAME)\n --backup-dir DIR --backup-id ID --scope config,schema,data [--model tree,table]\n [--include PATTERN] [--exclude PATTERN] [--start-time TIME] [--end-time TIME]\n [--include-auth true|false] [--mode snapshot|continuous] [--until ISO-8601]\n [--resume fail-if-exists|append|new] [--pipe-name NAME]\n [--segment-size-bytes 268435456] [--max-record-bytes 67108864]\n [--fsync-policy always|batch|periodic|none] [--fsync-batch-operations N|--fsync-period-ms N]\n [--on-unsupported fail|skip] [--include-heartbeat true|false]\n [--timeout-seconds 3600] [--poll-interval-seconds 2] [--json] [--allow-incomplete]\n continuous 必须提供 --until;--stop-sequence 是预留参数,当前会被拒绝。\n 对象过滤只允许选择一个模型:Tree 使用路径列表,Table 使用数据库正则/表名正则。\n Tree config/schema 捕获只接受单个前缀或完整路径,不支持排除过滤。\n Table 排除过滤必须覆盖整个数据库或整个表名模式。\nrestore: --input PATH [--host HOST --port PORT --user USER\n (--password-stdin|--password-env NAME)] [--dry-run] [--checkpoint FILE] [--resume]\n [--conflict fail] [--include-auth true|false] [--json] [--allow-incomplete]\n 恢复权限对象必须指定 --include-auth true;旧 config 备份按包含权限对象处理。\n 实际恢复需要目标连接;本地 dry-run 无需连接。\n 目标预检检查版本、时间精度、认证和握手能力;\n 对象冲突及对象级权限在回放时检查。\n\n弃用别名:export --input DIR --output ZIP = pack;\n 带源端连接参数的 export = dump 后接 pack;import = restore。\n退出码:0 成功,1 失败。错误及弃用提示输出到标准错误。";
}
diff --git a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/LogicalBackupDumpOptions.java b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/LogicalBackupDumpOptions.java
new file mode 100644
index 000000000000..e28e288df0b4
--- /dev/null
+++ b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/LogicalBackupDumpOptions.java
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.iotdb.tool.pipe;
+
+import org.apache.iotdb.cli.i18n.CliMessages;
+import org.apache.iotdb.commons.pipe.datastructure.pattern.IoTDBTreePatternOperations;
+import org.apache.iotdb.commons.pipe.datastructure.pattern.TreePattern;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupCatalog;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupSettings;
+import org.apache.iotdb.pipe.api.customizer.parameter.PipeParameters;
+
+import com.google.gson.Gson;
+import org.apache.commons.cli.CommandLine;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/** Converts the public dump contract to source and sink parameters before any remote mutation. */
+final class LogicalBackupDumpOptions {
+
+ final LogicalBackupCatalog catalog = new LogicalBackupCatalog();
+ final Map source = new LinkedHashMap<>();
+ final Map sink = new LinkedHashMap<>();
+ final String resume;
+ final long until;
+
+ LogicalBackupDumpOptions(final CommandLine line, final boolean legacy) throws IOException {
+ catalog.scope =
+ choices(
+ line.getOptionValue("scope", legacy ? "config,schema,data" : ""),
+ List.of("config", "schema", "data"),
+ "scope");
+ catalog.model =
+ choices(line.getOptionValue("model", "tree,table"), List.of("tree", "table"), "model");
+ catalog.mode = line.getOptionValue("mode", "snapshot");
+ if (!List.of("snapshot", "continuous").contains(catalog.mode)) {
+ throw invalid("mode");
+ }
+ final String includeAuth = line.getOptionValue("include-auth", "false");
+ if (!List.of("true", "false").contains(includeAuth)) {
+ throw invalid("include-auth");
+ }
+ catalog.includeAuth = Boolean.parseBoolean(includeAuth);
+ if (catalog.includeAuth && !catalog.scope.contains("config")) {
+ throw invalid("include-auth");
+ }
+ if (line.hasOption("stop-sequence")) {
+ throw new IOException(
+ CliMessages
+ .EXCEPTION_STOP_SEQUENCE_REQUIRES_A_STABLE_SOURCE_SEQUENCE_CAPABILITY_USE_UNTIL_WITH_THIS_SERVER_VERSION_8323B09E);
+ }
+ if ("continuous".equals(catalog.mode)) {
+ if (!line.hasOption("until")) {
+ throw invalid("until");
+ }
+ try {
+ until = Instant.parse(line.getOptionValue("until")).toEpochMilli();
+ } catch (final java.time.DateTimeException e) {
+ throw invalid("until");
+ }
+ if (until <= System.currentTimeMillis() && !"append".equals(line.getOptionValue("resume"))) {
+ throw invalid("until");
+ }
+ } else {
+ if (line.hasOption("until")) {
+ throw invalid("until");
+ }
+ until = -1;
+ }
+ resume = line.getOptionValue("resume", "fail-if-exists");
+ if (!List.of("fail-if-exists", "append", "new").contains(resume)) {
+ throw invalid("resume");
+ }
+ catalog.captureOptions.put("scope", String.join(",", catalog.scope));
+ catalog.captureOptions.put("model", String.join(",", catalog.model));
+ catalog.captureOptions.put("mode", catalog.mode);
+ catalog.captureOptions.put("include-auth", includeAuth);
+ for (final String key : List.of("include", "exclude", "start-time", "end-time", "until")) {
+ if (line.hasOption(key)) {
+ catalog.filters.put(key, line.getOptionValue(key));
+ catalog.captureOptions.put(key, line.getOptionValue(key));
+ }
+ }
+ catalog.filters.put("time-boundary", "closed-closed");
+ final List inclusion = new ArrayList<>();
+ if (catalog.scope.contains("config")) {
+ inclusion.add("schema.database");
+ inclusion.add("schema.ttl");
+ inclusion.add("schema.timeseries.template.create");
+ inclusion.add("schema.timeseries.template.set");
+ inclusion.add("schema.timeseries.template.unset");
+ inclusion.add("schema.timeseries.template.alter");
+ inclusion.add("schema.timeseries.template.drop");
+ }
+ if (catalog.scope.contains("schema")) {
+ inclusion.add("schema");
+ }
+ if (catalog.scope.contains("data")) {
+ inclusion.add("data");
+ }
+ if (catalog.includeAuth) {
+ inclusion.add("auth");
+ }
+ source.put("source", "iotdb-source");
+ source.put("source.inclusion", String.join(",", inclusion));
+ source.put("source.inclusion.exclusion", catalog.includeAuth ? "" : "auth");
+ source.put("source.skipif", "");
+ source.put("source.capture.tree", Boolean.toString(catalog.model.contains("tree")));
+ source.put("source.capture.table", Boolean.toString(catalog.model.contains("table")));
+ source.put("source.mode", "snapshot".equals(catalog.mode) ? "snapshot" : "live");
+ source.put("source.realtime.enable", Boolean.toString("continuous".equals(catalog.mode)));
+ source.put("source.logical-backup.snapshot", Boolean.toString("snapshot".equals(catalog.mode)));
+ if (until > 0) {
+ source.put("source.logical-backup.until", Long.toString(until));
+ source.put("source.realtime.mode", "forced-log");
+ }
+ // Decode TsFiles so the source applies exact path/time filters to logical rows.
+ source.put("source.mods.enable", "true");
+ for (final String key : List.of("start-time", "end-time")) {
+ if (line.hasOption(key)) {
+ source.put("source." + key, line.getOptionValue(key));
+ }
+ }
+ applyObjectFilters(line);
+ sink.put("sink", "logical-backup-sink");
+ sink.put("sink.resume", "append");
+ final Map defaults = new LinkedHashMap<>();
+ defaults.put("segment-size-bytes", "268435456");
+ defaults.put("max-record-bytes", "67108864");
+ defaults.put("fsync-policy", "always");
+ defaults.put("on-unsupported", "fail");
+ defaults.put("include-heartbeat", "false");
+ for (final Map.Entry entry : defaults.entrySet()) {
+ catalog.captureOptions.put(
+ entry.getKey(), line.getOptionValue(entry.getKey(), entry.getValue()));
+ sink.put(
+ "sink."
+ + ("on-unsupported".equals(entry.getKey()) ? "on-unsupported-event" : entry.getKey()),
+ catalog.captureOptions.get(entry.getKey()));
+ }
+ for (final String key : List.of("fsync-batch-operations", "fsync-period-ms")) {
+ if (line.hasOption(key)) {
+ sink.put("sink." + key, line.getOptionValue(key));
+ catalog.captureOptions.put(key, line.getOptionValue(key));
+ }
+ }
+ sink.put("sink.capture-options", new Gson().toJson(catalog.captureOptions));
+ // Reuse the same validation as all three server-side writers.
+ final Map validation = new LinkedHashMap<>(sink);
+ validation.put("sink.dir", line.getOptionValue("backup-dir", "."));
+ new LogicalBackupSettings(new PipeParameters(validation), "backup");
+ }
+
+ private void applyObjectFilters(final CommandLine line) throws IOException {
+ if (!line.hasOption("include") && !line.hasOption("exclude")) {
+ return;
+ }
+ if (catalog.model.size() != 1) {
+ throw new IOException(
+ CliMessages
+ .EXCEPTION_OBJECT_FILTERS_REQUIRE_MODEL_TREE_OR_MODEL_TABLE_SPLIT_MIXED_MODEL_FILTERED_DUMPS_INTO_SEPARATE_BACKUPS_0488699E);
+ }
+ if (catalog.model.contains("tree")) {
+ if (line.hasOption("include")) {
+ source.put("source.path.inclusion", line.getOptionValue("include"));
+ }
+ if (line.hasOption("exclude")) {
+ source.put("source.path.exclusion", line.getOptionValue("exclude"));
+ }
+ final TreePattern pattern =
+ TreePattern.parsePipePatternFromSourceParameters(new PipeParameters(source));
+ if (!pattern.isLegal()
+ || ((catalog.scope.contains("config") || catalog.scope.contains("schema"))
+ && !(pattern instanceof IoTDBTreePatternOperations
+ && ((IoTDBTreePatternOperations) pattern).isPrefixOrFullPath()))) {
+ throw invalid("include/exclude");
+ }
+ return;
+ }
+ // TablePattern accepts independent database/table regular expressions. Reject correlated
+ // exclusions rather than silently backing up their Cartesian product.
+ final String[] include = tableFilter(line.getOptionValue("include", ".*/.*"));
+ final String[] exclude =
+ line.hasOption("exclude") ? tableFilter(line.getOptionValue("exclude")) : null;
+ if (exclude != null && !".*".equals(exclude[0]) && !".*".equals(exclude[1])) {
+ throw new IOException(
+ CliMessages
+ .EXCEPTION_TABLE_FILTERS_USE_DATABASE_REGEX_TABLE_REGEX_EXCLUSIONS_MUST_USE_FOR_EITHER_DATABASE_OR_TABLE_42F0FA23);
+ }
+ for (int index = 0; index < 2; index++) {
+ String regex = include[index];
+ if (exclude != null && !".*".equals(exclude[index])) {
+ regex = "(?!(?:" + exclude[index] + ")$)(?:" + regex + ")";
+ }
+ source.put(index == 0 ? "source.database-name" : "source.table-name", regex);
+ }
+ }
+
+ private static String[] tableFilter(final String filter) throws IOException {
+ final String[] parts = filter.split("/", -1);
+ if (parts.length != 2 || parts[0].isBlank() || parts[1].isBlank()) {
+ throw new IOException(
+ CliMessages
+ .EXCEPTION_TABLE_FILTERS_USE_DATABASE_REGEX_TABLE_REGEX_EXCLUSIONS_MUST_USE_FOR_EITHER_DATABASE_OR_TABLE_42F0FA23);
+ }
+ try {
+ Pattern.compile(parts[0]);
+ Pattern.compile(parts[1]);
+ } catch (final java.util.regex.PatternSyntaxException e) {
+ throw invalid("include/exclude");
+ }
+ return parts;
+ }
+
+ private static List choices(
+ final String value, final List allowed, final String key) throws IOException {
+ final List values = Arrays.asList(value.split(",", -1));
+ if (values.isEmpty()
+ || !allowed.containsAll(values)
+ || values.stream().distinct().count() != values.size()) {
+ throw invalid(key);
+ }
+ // Canonical ordering makes equivalent requests compare equal when resuming.
+ final List canonical = new ArrayList<>(allowed);
+ canonical.retainAll(values);
+ return canonical;
+ }
+
+ String createSql(final String pipeName, final String root, final String backupId) {
+ final Map sinkOptions = new LinkedHashMap<>(sink);
+ sinkOptions.put("sink.dir", root);
+ sinkOptions.put("sink.backup-id", backupId);
+ return "CREATE PIPE "
+ + pipeName
+ + " WITH SOURCE ("
+ + sqlOptions(source)
+ + ") WITH SINK ("
+ + sqlOptions(sinkOptions)
+ + ")";
+ }
+
+ private static String sqlOptions(final Map options) {
+ final List values = new ArrayList<>();
+ options.forEach((key, value) -> values.add("'" + key + "'='" + value.replace("'", "''") + "'"));
+ return String.join(",", values);
+ }
+
+ private static IOException invalid(final String key) {
+ return new IOException(
+ String.format(CliMessages.EXCEPTION_INVALID_LOGICAL_BACKUP_DUMP_OPTION_ARG_894E11E0, key));
+ }
+}
diff --git a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
index 631353027a61..e73067ed69ea 100644
--- a/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
+++ b/iotdb-client/cli/src/main/java/org/apache/iotdb/tool/pipe/PipeLogicalBackupTool.java
@@ -25,9 +25,11 @@
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader.BackupStream;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupArchiveReader.EventGroup;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupCatalog;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupFormat;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupManifest;
import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupRecord;
+import org.apache.iotdb.commons.pipe.sink.logicalbackup.LogicalBackupSettings;
import org.apache.iotdb.commons.pipe.sink.payload.thrift.common.PipeTransferHandshakeConstant;
import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeRequestType;
import org.apache.iotdb.commons.pipe.sink.payload.thrift.request.PipeTransferHandshakeV2Req;
@@ -43,7 +45,6 @@
import com.google.gson.JsonParseException;
import org.apache.commons.cli.CommandLine;
import org.apache.commons.cli.DefaultParser;
-import org.apache.commons.cli.HelpFormatter;
import org.apache.commons.cli.Option;
import org.apache.commons.cli.Options;
import org.apache.commons.cli.ParseException;
@@ -53,7 +54,6 @@
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
-import java.io.PrintWriter;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -73,13 +73,16 @@
import java.util.Set;
import java.util.UUID;
import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
+import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/** Command line inspector, verifier, archiver and importer for Pipe logical backups. */
public final class PipeLogicalBackupTool {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+ private static final String COMMAND_DUMP = "dump";
+ private static final String COMMAND_PACK = "pack";
+ private static final String COMMAND_RESTORE = "restore";
private static final String COMMAND_INSPECT = "inspect";
private static final String COMMAND_VERIFY = "verify";
private static final String COMMAND_EXPORT = "export";
@@ -129,71 +132,199 @@ public static void main(final String[] args) {
}
}
- static int run(final String[] args) throws Exception {
+ public static int run(final String[] args) throws Exception {
if (args.length == 0 || "--help".equals(args[0]) || "-h".equals(args[0])) {
printUsage();
return 0;
}
final String command = args[0].toLowerCase(java.util.Locale.ROOT);
+ if (!List.of(
+ COMMAND_DUMP,
+ COMMAND_PACK,
+ COMMAND_RESTORE,
+ COMMAND_INSPECT,
+ COMMAND_VERIFY,
+ COMMAND_EXPORT,
+ COMMAND_IMPORT)
+ .contains(command)) {
+ throw new ParseException(
+ String.format(
+ CliMessages.EXCEPTION_UNKNOWN_LOGICAL_BACKUP_COMMAND_ARG_79275619, command));
+ }
+ if (java.util.Arrays.asList(args).contains("--help")
+ || java.util.Arrays.asList(args).contains("-h")) {
+ printUsage();
+ return 0;
+ }
final CommandLine line = parse(command, java.util.Arrays.copyOfRange(args, 1, args.length));
switch (command) {
case COMMAND_INSPECT:
return inspect(line);
case COMMAND_VERIFY:
return verify(line);
+ case COMMAND_DUMP:
+ final Path dumped = dump(line, false);
+ printDumpResult(line, dumped);
+ return 0;
+ case COMMAND_PACK:
+ return pack(line);
+ case COMMAND_RESTORE:
+ return importBackup(line);
case COMMAND_EXPORT:
- return export(line);
+ System.err.println(
+ CliMessages
+ .LOG_EXPORT_IS_DEPRECATED_USE_DUMP_TO_GENERATE_A_BACKUP_AND_PACK_TO_CREATE_A_ZIP_47709D2D);
+ if (!line.hasOption(OPTION_INPUT) && !line.hasOption(OPTION_SOURCE)) {
+ final Path source = dump(line, true);
+ final List streams =
+ new LogicalBackupArchiveReader()
+ .read(source, line.hasOption(OPTION_ALLOW_INCOMPLETE));
+ return exportArchive(
+ source,
+ Path.of(optionValue(line, OPTION_OUTPUT, OPTION_TARGET)),
+ streams,
+ line.hasOption(OPTION_JSON));
+ }
+ return pack(line);
case COMMAND_IMPORT:
+ System.err.println(CliMessages.LOG_IMPORT_IS_DEPRECATED_USE_RESTORE_2F1AE2CE);
return importBackup(line);
default:
- throw new ParseException(
- String.format(
- CliMessages.EXCEPTION_UNKNOWN_LOGICAL_BACKUP_COMMAND_ARG_79275619, command));
+ throw new AssertionError(command);
}
}
- private static CommandLine parse(final String command, final String[] args)
- throws ParseException {
+ static CommandLine parse(final String command, final String[] args) throws ParseException {
final Options options = new Options();
- addOption(options, OPTION_INPUT, true, false);
- addOption(options, OPTION_OUTPUT, true, false);
- addOption(options, OPTION_RESUME, true, false);
- addOption(options, OPTION_FORMAT, true, false);
- addOption(options, OPTION_DEEP, false, false);
- addOption(options, OPTION_SOURCE, true, false);
- addOption(options, OPTION_TARGET, true, false);
- addOption(options, OPTION_HOST, true, false);
- addOption(options, OPTION_PORT, true, false);
- addOption(options, OPTION_USER, true, false);
- addOption(options, OPTION_PASSWORD_STDIN, false, false);
- addOption(options, OPTION_PASSWORD_ENV, true, false);
- addOption(options, OPTION_CHECKPOINT, true, false);
- addOption(options, OPTION_DRY_RUN, false, false);
- addOption(options, OPTION_ALLOW_INCOMPLETE, false, false);
- addOption(options, OPTION_JSON, false, false);
- addOption(options, OPTION_BACKUP_DIR, true, false);
- addOption(options, OPTION_PIPE_NAME, true, false);
- addOption(options, OPTION_BACKUP_ID, true, false);
- addOption(options, OPTION_TIMEOUT_SECONDS, true, false);
- addOption(options, OPTION_POLL_INTERVAL_SECONDS, true, false);
+ final Set names = new LinkedHashSet<>(List.of(OPTION_JSON, OPTION_ALLOW_INCOMPLETE));
+ final boolean legacyExport = COMMAND_EXPORT.equals(command);
+ final boolean legacyImport = COMMAND_IMPORT.equals(command);
+ final boolean dump = COMMAND_DUMP.equals(command) || legacyExport;
+ final boolean restore = COMMAND_RESTORE.equals(command) || legacyImport;
+ if (!COMMAND_DUMP.equals(command)) {
+ names.add(OPTION_INPUT);
+ }
+ if (COMMAND_PACK.equals(command) || legacyExport) {
+ names.add(OPTION_OUTPUT);
+ }
+ if (dump || restore) {
+ names.addAll(
+ List.of(
+ OPTION_HOST,
+ OPTION_PORT,
+ OPTION_USER,
+ OPTION_PASSWORD_STDIN,
+ OPTION_PASSWORD_ENV,
+ OPTION_RESUME));
+ }
+ if (restore) {
+ names.addAll(List.of(OPTION_CHECKPOINT, OPTION_DRY_RUN, "conflict", "include-auth"));
+ }
+ if (dump) {
+ names.addAll(
+ List.of(
+ OPTION_BACKUP_DIR,
+ OPTION_BACKUP_ID,
+ OPTION_PIPE_NAME,
+ OPTION_TIMEOUT_SECONDS,
+ OPTION_POLL_INTERVAL_SECONDS,
+ "scope",
+ "model",
+ "include",
+ "exclude",
+ "start-time",
+ "end-time",
+ "include-auth",
+ "mode",
+ "until",
+ "stop-sequence",
+ "segment-size-bytes",
+ "max-record-bytes",
+ "fsync-policy",
+ "fsync-batch-operations",
+ "fsync-period-ms",
+ "on-unsupported",
+ "include-heartbeat"));
+ }
+ if (legacyExport || legacyImport) {
+ names.add(OPTION_SOURCE);
+ if (legacyExport) {
+ names.addAll(List.of(OPTION_TARGET, OPTION_FORMAT));
+ }
+ }
+ if (COMMAND_VERIFY.equals(command)) {
+ names.add(OPTION_DEEP); // Accepted for compatibility; verification is always deep.
+ }
+ final Set flags =
+ Set.of(
+ OPTION_JSON,
+ OPTION_ALLOW_INCOMPLETE,
+ OPTION_DRY_RUN,
+ OPTION_PASSWORD_STDIN,
+ OPTION_DEEP);
+ for (final String name : names) {
+ if (OPTION_RESUME.equals(name) && restore) {
+ options.addOption(
+ Option.builder().longOpt(name).hasArg(legacyImport).optionalArg(legacyImport).build());
+ } else {
+ addOption(options, name, !flags.contains(name), false);
+ }
+ }
final CommandLine line = new DefaultParser().parse(options, args);
- final String input = optionValue(line, OPTION_INPUT, OPTION_SOURCE);
- if ((input == null || input.isBlank())
- && (!COMMAND_EXPORT.equals(command) || !line.hasOption(OPTION_BACKUP_DIR))) {
+ if (!line.getArgList().isEmpty()
+ || (line.hasOption(OPTION_INPUT) && line.hasOption(OPTION_SOURCE))
+ || (line.hasOption(OPTION_OUTPUT) && line.hasOption(OPTION_TARGET))) {
+ throw new ParseException(
+ CliMessages.EXCEPTION_INVALID_OR_CONFLICTING_LOGICAL_BACKUP_ARGUMENTS_76233F81);
+ }
+ if (!COMMAND_DUMP.equals(command)
+ && !(legacyExport && line.hasOption(OPTION_BACKUP_DIR))
+ && optionValue(line, OPTION_INPUT, OPTION_SOURCE) == null) {
throw new ParseException(
String.format(
CliMessages.EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_IS_REQUIRED_1E7449AA, OPTION_INPUT));
}
- final String output = optionValue(line, OPTION_OUTPUT, OPTION_TARGET);
- if (COMMAND_EXPORT.equals(command) && (output == null || output.isBlank())) {
+ if ((COMMAND_PACK.equals(command) || legacyExport)
+ && optionValue(line, OPTION_OUTPUT, OPTION_TARGET) == null) {
throw new ParseException(
- CliMessages.EXCEPTION_OUTPUT_IS_REQUIRED_FOR_LOGICAL_BACKUP_EXPORT_603340B1);
+ String.format(
+ CliMessages.EXCEPTION_LOGICAL_BACKUP_OPTION_ARG_IS_REQUIRED_1E7449AA, OPTION_OUTPUT));
}
- final String format = line.getOptionValue(OPTION_FORMAT, "binary");
- if (COMMAND_EXPORT.equals(command) && !"binary".equalsIgnoreCase(format)) {
+ if (legacyExport && !"binary".equals(line.getOptionValue(OPTION_FORMAT, "binary"))) {
throw new ParseException(
String.format(
- CliMessages.EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_EXPORT_FORMAT_ARG_A6D7DEB1, format));
+ CliMessages.EXCEPTION_UNSUPPORTED_LOGICAL_BACKUP_EXPORT_FORMAT_ARG_A6D7DEB1,
+ line.getOptionValue(OPTION_FORMAT)));
+ }
+ if (line.hasOption(OPTION_PORT)) {
+ final long port = longOption(line, OPTION_PORT, 6667);
+ if (port <= 0 || port > 65535) {
+ throw new ParseException(
+ CliMessages.EXCEPTION_INVALID_OR_CONFLICTING_LOGICAL_BACKUP_ARGUMENTS_76233F81);
+ }
+ }
+ if (restore && !"fail".equals(line.getOptionValue("conflict", "fail"))) {
+ throw new ParseException(
+ CliMessages
+ .EXCEPTION_ONLY_CONFLICT_FAIL_IS_SUPPORTED_RESTORE_NEVER_IMPLICITLY_OVERWRITES_OR_DELETES_TARGET_OBJECTS_70C9523E);
+ }
+ // Offline export is exactly pack, including its refusal of source connection options.
+ if (legacyExport && optionValue(line, OPTION_INPUT, OPTION_SOURCE) != null) {
+ final Set packOptions =
+ Set.of(
+ OPTION_INPUT,
+ OPTION_SOURCE,
+ OPTION_OUTPUT,
+ OPTION_TARGET,
+ OPTION_FORMAT,
+ OPTION_JSON,
+ OPTION_ALLOW_INCOMPLETE);
+ for (final Option option : line.getOptions()) {
+ if (!packOptions.contains(option.getLongOpt())) {
+ throw new ParseException(
+ CliMessages.EXCEPTION_INVALID_OR_CONFLICTING_LOGICAL_BACKUP_ARGUMENTS_76233F81);
+ }
+ }
}
return line;
}
@@ -203,128 +334,228 @@ private static void addOption(
options.addOption(Option.builder().longOpt(name).hasArg(hasArg).required(required).build());
}
- private static int inspect(final CommandLine line) throws IOException {
- final List streams = read(line);
- if (line.hasOption(OPTION_JSON)) {
- final List